commit 076412dcdbe6b753a31f8d031eb8ae089146bc82 Author: diadmin Date: Mon Oct 26 10:43:20 2026 +0900 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7f9b0db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.sh text eol=lf +*.png binary +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d74121 --- /dev/null +++ b/.gitignore @@ -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__/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..0c146c2 --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..75d3171 --- /dev/null +++ b/README.md @@ -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` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..8694797 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,5 @@ +analyzer: + exclude: + - build/** + - android/** +include: package:flutter_lints/flutter.yaml diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/.gitkeep b/android/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..c959441 --- /dev/null +++ b/android/app/build.gradle.kts @@ -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") +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7046832 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/friendlymap/friendly_map/MainActivity.kt b/android/app/src/main/kotlin/com/friendlymap/friendly_map/MainActivity.kt new file mode 100644 index 0000000..e2fc702 --- /dev/null +++ b/android/app/src/main/kotlin/com/friendlymap/friendly_map/MainActivity.kt @@ -0,0 +1,5 @@ +package com.friendlymap.friendly_map + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/lib/composition/android_bootstrap.dart b/lib/composition/android_bootstrap.dart new file mode 100644 index 0000000..e56138d --- /dev/null +++ b/lib/composition/android_bootstrap.dart @@ -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 hasKeys() => _keys.isSetUp(); + + @override + Future createKeys() => _keys.setUpNew(); + + @override + Future 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> 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 recoveryPhrase() => _keys.showPhrase(); + + @override + Future 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; + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..dada664 --- /dev/null +++ b/lib/main.dart @@ -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(), + )); +} diff --git a/lib/modules/app/app.dart b/lib/modules/app/app.dart new file mode 100644 index 0000000..509a31f --- /dev/null +++ b/lib/modules/app/app.dart @@ -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'; diff --git a/lib/modules/app/src/app_deps.dart b/lib/modules/app/src/app_deps.dart new file mode 100644 index 0000000..23fb666 --- /dev/null +++ b/lib/modules/app/src/app_deps.dart @@ -0,0 +1,37 @@ +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/discovery/discovery.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +/// 앱이 쓰는 재료 묶음. 어떤 저장소·연결·암호를 쓸지는 조립하는 쪽(main)이 정한다. +class AppDeps { + AppDeps({ + required this.storage, + required this.transport, + required this.cipher, + required this.identity, + required this.probe, + this.timeSource, + this.lan, + this.location, + this.verifier, + this.callLog, + this.phone, + this.notifier, + Clock? clock, + }) : clock = clock ?? Clock(); + + final Storage storage; + final Transport transport; + final PayloadCipher cipher; + final IdentityProvider identity; + final DeviceStorageProbe probe; + final TimeSource? timeSource; + final LanDirectory? lan; + final LocationProvider? location; + final PhoneVerifier? verifier; + final CallLogProvider? callLog; + final PhoneActions? phone; + final Notifier? notifier; + final Clock clock; +} diff --git a/lib/modules/app/src/call_service.dart b/lib/modules/app/src/call_service.dart new file mode 100644 index 0000000..e5eae87 --- /dev/null +++ b/lib/modules/app/src/call_service.dart @@ -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 reachable(String personId) async { + final p = await _storage.people.get(personId); + return p != null && !isLocalPhone(p.phone); + } + + Future 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 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 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); + } +} diff --git a/lib/modules/app/src/card_service.dart b/lib/modules/app/src/card_service.dart new file mode 100644 index 0000000..91a0c35 --- /dev/null +++ b/lib/modules/app/src/card_service.dart @@ -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> 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> _order() async { + final t = await _storage.settings.getText(_orderKey); + if (t == null) return []; + try { + return (jsonDecode(t) as List).cast(); + } catch (_) { + return []; + } + } + + Future 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 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 edit(String cardId, {String? title, Map? 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 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 shareWith(String personId, List 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 stopSharing(String shareId) async { + await _share.stop(shareId); + _bus.notify(); + } + + /// 이 명함을 지금 공유 중인 곳들. + Future> sharesOf(String cardId) => _storage.shares.activeFor(cardId); + + /// 고쳐서 다시 보내야 하는 공유들. + Future> needingResend() => _share.needingResend(); + + /// 사용자가 "다시 보내기"를 눌렀을 때만 고친 내용이 상대에게 간다. + Future resend(String shareId) async { + final updated = await _share.resend(shareId); + if (updated == null) return false; + await _sync.sendCard(updated); + _bus.notify(); + return true; + } + + /// 공유 상태 (공유 안 함 / 최신 / 다시 보내기 필요). + Future statusOf(ShareRecord share) async { + final card = await _storage.myCards.get(share.targetId); + return shareStatusOf(share, card?.updatedAt); + } +} diff --git a/lib/modules/app/src/change_bus.dart b/lib/modules/app/src/change_bus.dart new file mode 100644 index 0000000..8c9af7e --- /dev/null +++ b/lib/modules/app/src/change_bus.dart @@ -0,0 +1,14 @@ +import 'dart:async'; + +/// "무언가 바뀌었다"를 화면에 알리는 통로. 화면은 이것을 듣고 다시 읽는다. +class ChangeBus { + final StreamController _ctl = StreamController.broadcast(); + + Stream get changes => _ctl.stream; + + void notify() { + if (!_ctl.isClosed) _ctl.add(null); + } + + Future close() => _ctl.close(); +} diff --git a/lib/modules/app/src/chat_service.dart b/lib/modules/app/src/chat_service.dart new file mode 100644 index 0000000..53f4a47 --- /dev/null +++ b/lib/modules/app/src/chat_service.dart @@ -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> summaries() async { + final out = []; + 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> messages(String roomId, {int? before, int limit = 50}) => + _storage.messages.page(roomId, before: before, limit: limit); + + /// 이 상대와 [contextKind] 맥락의 1:1 방을 연다 (없으면 만든다). + /// 개인 대화는 명함 없이 되고, 그 밖의 맥락은 그 종류의 명함을 이 상대에게 공유해야 한다. 안 되면 null. + Future 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 createGroup(String title, List 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 _create(String title, String kind, String ctx, List 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 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; + } +} diff --git a/lib/modules/app/src/event_service.dart b/lib/modules/app/src/event_service.dart new file mode 100644 index 0000000..45fc127 --- /dev/null +++ b/lib/modules/app/src/event_service.dart @@ -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> leadDays() async { + final raw = await _storage.settings.getText(_leadKey); + return raw == null ? _defaultLead : parseLeadDays(raw); + } + + Future setLeadDays(String text) async { + await _storage.settings.putText(_leadKey, leadDaysText(parseLeadDays(text)), _clock.now()); + _bus.notify(); + await reschedule(); + } + + /// 돌아가신 분 표시를 보일지 (기본 보임). + Future showDeceased() async => (await _storage.settings.getText(_showKey)) != 'false'; + + Future 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 _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> 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> soon({int withinDays = 30}) async { + final out = []; + 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 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 = []; + 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; + } +} diff --git a/lib/modules/app/src/facts_service.dart b/lib/modules/app/src/facts_service.dart new file mode 100644 index 0000000..0d66657 --- /dev/null +++ b/lib/modules/app/src/facts_service.dart @@ -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 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? 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(); + 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 get(String personId) async => + PersonFacts.fromJson(await _storage.settings.getText(_key(personId))); + + Future 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 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> 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]; + } +} diff --git a/lib/modules/app/src/fields.dart b/lib/modules/app/src/fields.dart new file mode 100644 index 0000000..22e56ba --- /dev/null +++ b/lib/modules/app/src/fields.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +/// 명함 항목(JSON 글자) ↔ 이름-값 지도. +Map 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 fields) => jsonEncode(fields); diff --git a/lib/modules/app/src/friend_service.dart b/lib/modules/app/src/friend_service.dart new file mode 100644 index 0000000..26fa63b --- /dev/null +++ b/lib/modules/app/src/friend_service.dart @@ -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> 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 addNearby(String phone) => add(phone, phone); + + Future> people() async { + final list = await _storage.people.all(); + list.sort((a, b) => a.name.compareTo(b.name)); + return list; + } + + Future person(String id) => _storage.people.get(id); + + Future> requests() => _storage.friendRequests.pending(); + + /// 1단계: 번호·이름을 확인하고, 이미 아는 사람과 겹치는지 알려 준다. 같은 사람인지는 사용자가 고른다. + Future 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 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 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 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 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 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 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 _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(); + } +} diff --git a/lib/modules/app/src/friendly_app.dart b/lib/modules/app/src/friendly_app.dart new file mode 100644 index 0000000..05956dc --- /dev/null +++ b/lib/modules/app/src/friendly_app.dart @@ -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? _events; + final Set> _tasks = {}; + + /// 화면이 듣는 변경 알림. + Stream get changes => bus.changes; + + /// 연결을 열고 동기화를 시작한다. 내 정보를 정한 뒤에 부른다. + Future 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 idle() async { + do { + await sync.idle(); + if (_tasks.isNotEmpty) await Future.wait(_tasks.toList()); + } while (_tasks.isNotEmpty); + } + + /// 보관함에서 새 봉투를 가져오고 못 보낸 것을 다시 보낸다 (화면 당겨서 새로고침, 주기적 호출). + Future refresh() async { + await sync.refresh(); + bus.notify(); + } + + Future dispose() async { + await _events?.cancel(); + await deps.lan?.stop(); + await sync.stop(); + await deps.storage.close(); + await bus.close(); + } +} diff --git a/lib/modules/app/src/geo.dart b/lib/modules/app/src/geo.dart new file mode 100644 index 0000000..29f6765 --- /dev/null +++ b/lib/modules/app/src/geo.dart @@ -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; diff --git a/lib/modules/app/src/kin_service.dart b/lib/modules/app/src/kin_service.dart new file mode 100644 index 0000000..103ab48 --- /dev/null +++ b/lib/modules/app/src/kin_service.dart @@ -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 _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 _save(KinGraph g) async { + await _storage.settings.putText(_key, g.toJson(), _clock.now()); + _bus.notify(); + } + + /// 계산용 가계도 (생년은 사람별 사실의 값이 있으면 그걸 쓴다). + Future 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 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 me() async => (await _load()).person(KinGraph.meId)!; + + /// 가족을 한 명 더한다. 번호를 모르면 이 기기 안에서만 쓰는 사람이 된다 (친구 요청은 가지 않는다). + Future 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 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 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 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 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 unlinkParent(String child, String parent) async { + final g = await _load(); + g.removeParent(child, parent); + await _save(g); + } + + Future unlinkSpouse(String a, String b) async { + final g = await _load(); + g.removeSpouse(a, b); + await _save(g); + } + + /// 가계도에서 뺀다 (연락처 사람은 그대로 둔다). + Future exclude(String personId) async { + final g = await _load(); + g.removePerson(personId); + await _save(g); + } + + Future inTree(String personId) async => (await _load()).has(personId); + + /// 내가 보는 이 사람. 가계도에 없거나 이어진 길이 없으면 related == false. + Future relationOf(String personId) async => relate(await graph(), KinGraph.meId, personId); + + /// "내 아들 철수에게는 당숙 (5촌)" 처럼, 나와 내 자식들 각각의 눈으로 본 관계. + Future> 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> everyone() async { + final g = await graph(); + final out = []; + 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; + } +} diff --git a/lib/modules/app/src/layout_service.dart b/lib/modules/app/src/layout_service.dart new file mode 100644 index 0000000..e6e09a7 --- /dev/null +++ b/lib/modules/app/src/layout_service.dart @@ -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 read(String key) => _settings.getText(key); + + @override + Future 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 _home; + final OrderedStore _templates; + final ChangeBus _bus; + + Future> home() => _home.load(); + Future> templates() => _templates.load(); + + Future moveHome(int from, int to) => _do(() => _home.move(from, to)); + Future removeHome(String id) => _do(() => _home.remove(id)); + Future editHome(HomeItem item) => _do(() => _home.upsert(item)); + Future resetHome() => _do(_home.reset); + + Future saveTemplate(CardTemplate t) => _do(() => _templates.upsert(t)); + Future removeTemplate(String id) => _do(() => _templates.remove(id)); + Future moveTemplate(int from, int to) => _do(() => _templates.move(from, to)); + Future resetTemplates() => _do(_templates.reset); + + Future _do(Future Function() op) async { + await op(); + _bus.notify(); + } +} diff --git a/lib/modules/app/src/location_service.dart b/lib/modules/app/src/location_service.dart new file mode 100644 index 0000000..f175661 --- /dev/null +++ b/lib/modules/app/src/location_service.dart @@ -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 sharingOn() async => (await _storage.settings.getText(_onKey)) == 'true'; + + Future setSharing(bool on) async { + await _storage.settings.putText(_onKey, on ? 'true' : 'false', _clock.now()); + _bus.notify(); + } + + /// 내 현재 위치. 얻지 못하면 null. + Future mine() async => _provider?.current(); + + /// 지금 위치를 받을 수 있는 친구들과 단계(공개/일부). + Future> recipients() async { + final matrix = await _visibility.matrix(); + final out = {}; + 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 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> friends() async { + final raw = await _storage.settings.getText(friendLocationIndexKey); + if (raw == null || raw.isEmpty) return []; + final out = []; + for (final phone in (jsonDecode(raw) as List).cast()) { + 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(); + 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; +} diff --git a/lib/modules/app/src/network_service.dart b/lib/modules/app/src/network_service.dart new file mode 100644 index 0000000..248b07e --- /dev/null +++ b/lib/modules/app/src/network_service.dart @@ -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 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> _mine() async => [ + for (final c in await _storage.myCards.all()) CardSnapshot(c.kind, decodeFields(c.fieldsJson)), + ]; + + Future> _theirs(String personId) async => [ + for (final c in await _storage.receivedCards.byPerson(personId)) + CardSnapshot(c.kind, decodeFields(c.fieldsJson)), + ]; + + /// "어디서 봤지?" 추측 목록 (신뢰도 높은 순). + Future> guessesFor(String personId) async => + _inferer.infer(mine: await _mine(), theirs: await _theirs(personId)); + + /// 이 사람에게서 받은 명함들 (최근 것부터). + Future> receivedCards(String personId) => _storage.receivedCards.byPerson(personId); + + /// 받은 명함을 내 기기에서 지운다 (상대 기기의 원본은 그대로). + Future deleteReceivedCard(String cardId) async { + await _storage.receivedCards.remove(cardId); + _bus.notify(); + } + + Future> links(String personId) => _storage.links.byPerson(personId); + + /// 추측을 연결 고리로 확정하거나, 직접 관계를 적는다. + Future 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 removeLink(String linkId) async { + await _storage.links.remove(linkId); + _bus.notify(); + } + + /// 이미 아는 사람 중 나와 겹치는데 아직 연결 고리로 확정하지 않은 사람. + Future> suggestions() async { + final mine = await _mine(); + final others = >{}; + 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> clusters(String kind) async { + final keys = switch (kind) { + CardKinds.company => [CardKeys.company], + CardKinds.school => [CardKeys.school], + CardKinds.group => [CardKeys.group, CardKeys.trip], + _ => const [], + }; + final people = {for (final p in await _storage.people.all()) p.id: p}; + final byName = )>{}; + 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> family() async { + final people = {for (final p in await _storage.people.all()) p.id: p}; + final out = {}; + 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(); + } +} diff --git a/lib/modules/app/src/profile_service.dart b/lib/modules/app/src/profile_service.dart new file mode 100644 index 0000000..7f1ff28 --- /dev/null +++ b/lib/modules/app/src/profile_service.dart @@ -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 me() => _storage.settings.loadProfile(); + + Future isSetUp() async => (await me()) != null; + + /// 처음 시작: 내 번호·이름을 정하고 종류별 기본 명함을 만든다. 번호가 올바르지 않으면 null. + Future 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 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(); + } +} diff --git a/lib/modules/app/src/recovery_service.dart b/lib/modules/app/src/recovery_service.dart new file mode 100644 index 0000000..fe6c040 --- /dev/null +++ b/lib/modules/app/src/recovery_service.dart @@ -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 codes; + final int threshold; + final Map given; // 사람 ID → 조각 번호(0부터) + + int get total => codes.length; + int get entrusted => given.length; + + /// 아직 아무에게도 맡기지 않은 조각 번호들. + List get free => [for (var i = 0; i < codes.length; i++) if (!given.containsValue(i)) i]; + + Map toJson() => {'codes': codes, 'k': threshold, 'given': given}; + + factory RecoverySplit.fromJson(Map j) => RecoverySplit( + codes: (j['codes'] as List).cast(), + 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 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()); + } catch (_) { + return null; + } + } + + /// 새로 나눈 조각들을 보관한다. 이전 묶음과 맡긴 기록은 사라진다 (이전 조각은 새 조각과 섞이지 않는다). + Future saveSplit(List codes, int threshold) async { + final s = RecoverySplit(codes: codes, threshold: threshold); + await _write(s); + return s; + } + + /// [personId] 에게 [index] 번째 조각을 맡긴다. 이미 다른 조각을 맡았으면 그 조각을 새로 보낸다. + Future 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> held() async { + final raw = await _storage.settings.getText(heldRecoveryIndexKey); + if (raw == null || raw.isEmpty) return []; + final out = []; + for (final phone in (jsonDecode(raw) as List).cast()) { + 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 _write(RecoverySplit s) async { + await _storage.settings.putText(_splitKey, jsonEncode(s.toJson()), _clock.now()); + _bus.notify(); + } +} diff --git a/lib/modules/app/src/settings_service.dart b/lib/modules/app/src/settings_service.dart new file mode 100644 index 0000000..880271b --- /dev/null +++ b/lib/modules/app/src/settings_service.dart @@ -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 load() => _storage.settings.load(); + + Future save(AppSettings s) async { + await _storage.settings.save(s, _clock.now()); + _bus.notify(); + } + + /// 값 하나만 바꾼다. + Future 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 hideAll() async => (await _storage.settings.getText(hideAllKey)) == 'true'; + + Future setHideAll(bool value) async { + await _storage.settings.putText(hideAllKey, value ? 'true' : 'false', _clock.now()); + _bus.notify(); + } + + /// 이 방을 별도 저장으로 나누라고 권할 만한지. + Future adviseRoom(String roomId) => _archive.adviseRoom(roomId); + + /// 별도 저장으로 나눌 수 있는 범위 (처음~끝). 대화가 없으면 null. + Future<(int, int)?> selectableSpan(String roomId) => _archive.selectableSpan(roomId); + + Future archiveRange(String roomId, int from, int to) async { + final n = await _archive.archiveRange(roomId, from, to); + _bus.notify(); + return n; + } + + Future restoreRange(String roomId, int from, int to) async { + final n = await _archive.restoreRange(roomId, from, to); + _bus.notify(); + return n; + } +} diff --git a/lib/modules/app/src/verify_service.dart b/lib/modules/app/src/verify_service.dart new file mode 100644 index 0000000..c1cb1db --- /dev/null +++ b/lib/modules/app/src/verify_service.dart @@ -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 isVerified() async => (await _storage.settings.getText(_key)) == 'true'; + + Future request() async => await _verifier?.requestCode() ?? VerifyStart.unavailable; + + /// 확인 번호를 넣는다. 맞으면 "확인됨"으로 기록한다. + Future 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; + } +} diff --git a/lib/modules/app/src/visibility_service.dart b/lib/modules/app/src/visibility_service.dart new file mode 100644 index 0000000..cb44950 --- /dev/null +++ b/lib/modules/app/src/visibility_service.dart @@ -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 matrix() async => VisibilityMatrix.fromJson(await _storage.settings.getText(_key)); + + /// 표의 칸 하나를 다음 단계로 돌린다 (공개 → 일부 → 숨김). + Future 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 reset() async { + await _storage.settings.putText(_key, VisibilityMatrix.defaults().toJson(), _clock.now()); + _bus.notify(); + } + + /// 이 사람이 속한 관계 묶음: 연결된 관계에서 찾는다. + Future> audiencesOf(String personId) async { + final out = {}; + 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 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); + } +} diff --git a/lib/modules/core/core.dart b/lib/modules/core/core.dart new file mode 100644 index 0000000..69a2808 --- /dev/null +++ b/lib/modules/core/core.dart @@ -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'; diff --git a/lib/modules/core/src/card_keys.dart b/lib/modules/core/src/card_keys.dart new file mode 100644 index 0000000..923dafa --- /dev/null +++ b/lib/modules/core/src/card_keys.dart @@ -0,0 +1,35 @@ +/// 명함 항목(fields JSON)의 이름과 명함 종류. 모듈들이 같은 말을 쓰도록 한곳에 둔다. +class CardKinds { + static const personal = 'personal'; + static const company = 'company'; + static const school = 'school'; + static const group = 'group'; + static const family = 'family'; + static const all = [personal, company, school, group, family]; + + static String label(String kind) => switch (kind) { + personal => '개인', + company => '회사', + school => '학교', + group => '그룹·여행', + family => '가족', + _ => kind, + }; +} + +class CardKeys { + static const name = 'name'; + static const phone = 'phone'; + static const email = 'email'; + static const birthday = 'birthday'; + static const company = 'company'; + static const position = 'position'; + static const former = 'former'; // 'true' 이면 지금은 다니지 않는 회사 + static const school = 'school'; + static const level = 'level'; // 초등 | 중등 | 고등 | 대학 | 대학원 + static const year = 'year'; // 입학 또는 졸업 연도 + static const major = 'major'; + static const group = 'group'; + static const trip = 'trip'; + static const relation = 'relation'; // 가족: 어머니, 동생 … +} diff --git a/lib/modules/core/src/envelope.dart b/lib/modules/core/src/envelope.dart new file mode 100644 index 0000000..f70149c --- /dev/null +++ b/lib/modules/core/src/envelope.dart @@ -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 toJson() => { + 'id': id, + 'from': from, + 'to': to, + 'kind': kind, + 'unit': unitId, + 'body': body, + 'stamp': stamp, + }; + + factory Envelope.fromJson(Map 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()); +} diff --git a/lib/modules/core/src/identity_provider.dart b/lib/modules/core/src/identity_provider.dart new file mode 100644 index 0000000..f0638c4 --- /dev/null +++ b/lib/modules/core/src/identity_provider.dart @@ -0,0 +1,5 @@ +/// 내 공개 키를 내주는 약속. 상대는 이 값으로 나와 둘만의 암호 키를 만든다. +abstract class IdentityProvider { + /// 공개 키 (base64). 공개해도 안전하다. + Future myPublicKey(); +} diff --git a/lib/modules/core/src/key_provider.dart b/lib/modules/core/src/key_provider.dart new file mode 100644 index 0000000..ee056d5 --- /dev/null +++ b/lib/modules/core/src/key_provider.dart @@ -0,0 +1,6 @@ +/// 암호 키를 내주는 약속. 키 모듈은 이 약속을 지키는 하나의 구현일 뿐이라, +/// 다른 구현으로 바꾸거나 빼도 이 약속을 쓰는 모듈은 바뀌지 않는다. +abstract class KeyProvider { + /// 암호화된 저장소를 여는 암호. + Future databasePassword(); +} diff --git a/lib/modules/core/src/payload_cipher.dart b/lib/modules/core/src/payload_cipher.dart new file mode 100644 index 0000000..5761d7f --- /dev/null +++ b/lib/modules/core/src/payload_cipher.dart @@ -0,0 +1,17 @@ +/// 상대와 둘만 아는 키로 내용을 잠그고 푸는 약속. +/// +/// [context] 는 공유 단위(명함 장·대화방)의 ID 이고, 단위마다 다른 키가 만들어진다. +abstract class PayloadCipher { + Future seal({ + required String plain, + required String peerPublicKey, + required String context, + }); + + /// 풀 수 없거나(키가 다름) 중간에 바뀐 내용이면 null. + Future open({ + required String sealed, + required String peerPublicKey, + required String context, + }); +} diff --git a/lib/modules/core/src/phone_verifier.dart b/lib/modules/core/src/phone_verifier.dart new file mode 100644 index 0000000..08b17e8 --- /dev/null +++ b/lib/modules/core/src/phone_verifier.dart @@ -0,0 +1,11 @@ +/// 문자 요청 결과. +enum VerifyStart { sent, rateLimited, failed, unavailable } + +/// 이 기기가 내 전화번호의 주인인지 문자로 확인하는 약속. 보관함 서버의 방식이든 다른 방식이든 이 약속만 지키면 된다. +abstract class PhoneVerifier { + /// 내 번호로 확인 번호 문자를 요청한다. + Future requestCode(); + + /// 받은 확인 번호를 넣는다. 맞으면 true. + Future confirm(String code); +} diff --git a/lib/modules/core/src/time_source.dart b/lib/modules/core/src/time_source.dart new file mode 100644 index 0000000..61fa5fd --- /dev/null +++ b/lib/modules/core/src/time_source.dart @@ -0,0 +1,5 @@ +/// 기준 시각을 알려 주는 약속. 기기 시계 오차를 바로잡는 데 쓴다. +abstract class TimeSource { + /// 기준 시각 (1970년부터 지난 1/100초). 알 수 없으면 null. + Future referenceStamp(); +} diff --git a/lib/modules/core/src/transport.dart b/lib/modules/core/src/transport.dart new file mode 100644 index 0000000..57ba5de --- /dev/null +++ b/lib/modules/core/src/transport.dart @@ -0,0 +1,16 @@ +import 'envelope.dart'; + +/// 봉투를 주고받는 약속. 직접 연결, 외부 DB 보관함, 메모리(테스트용)가 이 약속을 지킨다. +abstract class Transport { + Future start(); + Future stop(); + + /// 보내 본다. 상대(또는 보관함)에게 닿았으면 true, 못 보냈으면 false. + Future send(Envelope envelope); + + /// 들어오는 봉투. + Stream get incoming; + + /// 보관함에 쌓인 것을 가져와 [incoming] 으로 흘려 보낸다. 가져온 개수를 돌려준다. + Future fetchPending(); +} diff --git a/lib/modules/discovery/discovery.dart b/lib/modules/discovery/discovery.dart new file mode 100644 index 0000000..0eca632 --- /dev/null +++ b/lib/modules/discovery/discovery.dart @@ -0,0 +1,9 @@ +/// 발견: 새 친구를 만나는 방법 (QR, 흔들기, 같은 네트워크 주소록) +/// +/// 센서·네트워크는 platform 의 약속으로만 받는다. 이 모듈은 숫자·글자 계산만 해서 어디서나 시험된다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/lan_directory.dart'; +export 'src/qr_payload.dart'; +export 'src/shake_detector.dart'; diff --git a/lib/modules/discovery/src/lan_directory.dart b/lib/modules/discovery/src/lan_directory.dart new file mode 100644 index 0000000..a4ce1c0 --- /dev/null +++ b/lib/modules/discovery/src/lan_directory.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:friendly_map/modules/platform/platform.dart'; + +/// 같은 네트워크에서 발견된 상대의 주소록. 전화번호로 주소를 찾는다. +/// 발견 방식(구현)은 [LanDiscovery] 약속으로만 받아서 플랫폼마다 갈아 끼울 수 있다. +class LanDirectory { + LanDirectory(this._discovery); + + final LanDiscovery _discovery; + final Map _byPhone = {}; + final StreamController _seen = StreamController.broadcast(sync: true); + StreamSubscription? _sub; + + /// 새로 발견되거나 주소가 바뀐 상대. + Stream get seen => _seen.stream; + + Future 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 stop() async { + await _sub?.cancel(); + _sub = null; + await _discovery.stop(); + } + + LanPeer? find(String phone) => _byPhone[phone]; + List get all => _byPhone.values.toList(); + + /// 사라진 상대를 지운다. + void forget(String phone) => _byPhone.remove(phone); +} diff --git a/lib/modules/discovery/src/qr_payload.dart b/lib/modules/discovery/src/qr_payload.dart new file mode 100644 index 0000000..9e61c7c --- /dev/null +++ b/lib/modules/discovery/src/qr_payload.dart @@ -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; + } + } +} diff --git a/lib/modules/discovery/src/shake_detector.dart b/lib/modules/discovery/src/shake_detector.dart new file mode 100644 index 0000000..f1c0c9b --- /dev/null +++ b/lib/modules/discovery/src/shake_detector.dart @@ -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 _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; + } +} diff --git a/lib/modules/events/events.dart b/lib/modules/events/events.dart new file mode 100644 index 0000000..432f60b --- /dev/null +++ b/lib/modules/events/events.dart @@ -0,0 +1,4 @@ +export 'src/calls.dart'; +export 'src/event.dart'; +export 'src/lunar.dart'; +export 'src/reminders.dart'; diff --git a/lib/modules/events/src/calls.dart b/lib/modules/events/src/calls.dart new file mode 100644 index 0000000..d647a56 --- /dev/null +++ b/lib/modules/events/src/calls.dart @@ -0,0 +1,89 @@ +enum CallDirection { incoming, outgoing, missed } + +class CallRecord { + const CallRecord(this.at, this.seconds, this.direction); + final DateTime at; + final int seconds; + final CallDirection direction; + bool get answered => direction != CallDirection.missed && seconds > 0; +} + +/// 통화 가능 시간대 하나 (예: 19시~21시). +class CallWindow { + const CallWindow(this.fromHour, this.toHour, this.count); + final int fromHour; + final int toHour; + final int count; + String get label => '${_ampm(fromHour)} ~ ${_ampm(toHour)}'; + static String _ampm(int h) { + final x = h % 24; + final p = x < 12 ? '오전' : '오후'; + final v = x % 12 == 0 ? 12 : x % 12; + return '$p $v시'; + } +} + +class CallSummary { + const CallSummary({ + required this.total, + required this.answered, + required this.missed, + required this.totalSeconds, + required this.lastAt, + required this.lastDirection, + required this.windows, + }); + static const empty = CallSummary( + total: 0, answered: 0, missed: 0, totalSeconds: 0, lastAt: null, lastDirection: null, windows: []); + + final int total; + final int answered; + final int missed; + final int totalSeconds; + final DateTime? lastAt; + final CallDirection? lastDirection; + final List windows; + + int get averageSeconds => answered == 0 ? 0 : totalSeconds ~/ answered; +} + +/// 통화 기록 한 사람 몫을 요약한다. 받은 통화만 "받을 수 있는 시간대" 계산에 쓴다. +CallSummary summarizeCalls(Iterable 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.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.generate(24, (i) => i)..sort((a, b) => byHour[b].compareTo(byHour[a])); + final windows = []; + 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}분'; +} diff --git a/lib/modules/events/src/event.dart b/lib/modules/events/src/event.dart new file mode 100644 index 0000000..a8bbdfb --- /dev/null +++ b/lib/modules/events/src/event.dart @@ -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 upcoming(Iterable events, DateTime today, {int withinDays = 30}) { + final t = DateTime(today.year, today.month, today.day); + final out = []; + 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일 후'); diff --git a/lib/modules/events/src/lunar.dart b/lib/modules/events/src/lunar.dart new file mode 100644 index 0000000..dea8476 --- /dev/null +++ b/lib/modules/events/src/lunar.dart @@ -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? _years; + +Map _table() { + final m = {}; + 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'; diff --git a/lib/modules/events/src/reminders.dart b/lib/modules/events/src/reminders.dart new file mode 100644 index 0000000..180e310 --- /dev/null +++ b/lib/modules/events/src/reminders.dart @@ -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 parseLeadDays(String? raw) { + if (raw == null) return const []; + final set = {}; + 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 days) => days.join(', '); + +/// 앞으로 [horizonDays] 안에 울려야 할 알림 전체. 이미 지난 시각은 뺀다. +/// [nameOf] 로 사람 이름을 얻는다. 알림은 [hour]시(기본 9시)에 울린다. +List planReminders( + Iterable events, + DateTime now, + List leadDays, + String Function(String personId) nameOf, { + int horizonDays = 60, + int hour = 9, +}) { + final out = []; + 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; +} diff --git a/lib/modules/inference/inference.dart b/lib/modules/inference/inference.dart new file mode 100644 index 0000000..bc0e8d8 --- /dev/null +++ b/lib/modules/inference/inference.dart @@ -0,0 +1,9 @@ +/// 유추: "어디서 봤지?" — 내 명함과 상대 명함을 견주어 사이를 추측한다. +/// +/// 저장소·화면을 모르는 순수 계산이라 따로 시험하고 그대로 어디서나 쓴다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/card_snapshot.dart'; +export 'src/guess.dart'; +export 'src/inferer.dart'; diff --git a/lib/modules/inference/src/card_snapshot.dart b/lib/modules/inference/src/card_snapshot.dart new file mode 100644 index 0000000..ddea52d --- /dev/null +++ b/lib/modules/inference/src/card_snapshot.dart @@ -0,0 +1,12 @@ +/// 유추에 쓰는 명함 내용. 저장소 모양과 상관없이 종류와 항목(이름→값)만 안다. +class CardSnapshot { + const CardSnapshot(this.kind, this.fields); + + final String kind; + final Map fields; + + String? operator [](String key) { + final v = fields[key]?.trim(); + return (v == null || v.isEmpty) ? null : v; + } +} diff --git a/lib/modules/inference/src/guess.dart b/lib/modules/inference/src/guess.dart new file mode 100644 index 0000000..d632bdf --- /dev/null +++ b/lib/modules/inference/src/guess.dart @@ -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; +} diff --git a/lib/modules/inference/src/inferer.dart b/lib/modules/inference/src/inferer.dart new file mode 100644 index 0000000..5c7fe3c --- /dev/null +++ b/lib/modules/inference/src/inferer.dart @@ -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 infer({required List mine, required List theirs}) { + final out = {}; + 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 recommend({ + required List mine, + required Map> others, + int minConfidence = 60, + }) { + final out = []; + 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; + } +} diff --git a/lib/modules/keys/keys.dart b/lib/modules/keys/keys.dart new file mode 100644 index 0000000..70492cb --- /dev/null +++ b/lib/modules/keys/keys.dart @@ -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'; diff --git a/lib/modules/keys/src/encoding.dart b/lib/modules/keys/src/encoding.dart new file mode 100644 index 0000000..ac007db --- /dev/null +++ b/lib/modules/keys/src/encoding.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +import 'package:cryptography/cryptography.dart'; + +String bytesToHex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + +List 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> deriveKeyBytes(List 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 [], + info: utf8.encode(label), + ); + return key.extractBytes(); +} diff --git a/lib/modules/keys/src/identity_keys.dart b/lib/modules/keys/src/identity_keys.dart new file mode 100644 index 0000000..9b81f77 --- /dev/null +++ b/lib/modules/keys/src/identity_keys.dart @@ -0,0 +1,20 @@ +import 'package:cryptography/cryptography.dart'; + +/// 용도 이름으로 비밀값을 만들어 주는 함수. 기본 비밀에서 용도별로 서로 다른 값이 나온다. +typedef SecretDeriver = Future> Function(String label); + +/// 내 신원 키(X25519). 기본 비밀에서 만들어지므로 복구 코드로 되살리면 같은 키가 나온다. +/// 공개 키만 상대에게 알리고, 비밀 키는 이 객체 밖으로 나가지 않는다. +class IdentityKeys { + IdentityKeys(this._derive); + + static const label = 'friendly_map/identity/v1'; + + final SecretDeriver _derive; + SimpleKeyPair? _pair; + + Future keyPair() async => + _pair ??= await X25519().newKeyPairFromSeed(await _derive(label)); + + Future> publicKeyBytes() async => (await (await keyPair()).extractPublicKey()).bytes; +} diff --git a/lib/modules/keys/src/local_identity.dart b/lib/modules/keys/src/local_identity.dart new file mode 100644 index 0000000..000980f --- /dev/null +++ b/lib/modules/keys/src/local_identity.dart @@ -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 myPublicKey() async => base64Encode(await _keys.publicKeyBytes()); +} diff --git a/lib/modules/keys/src/local_key_provider.dart b/lib/modules/keys/src/local_key_provider.dart new file mode 100644 index 0000000..64a73a8 --- /dev/null +++ b/lib/modules/keys/src/local_key_provider.dart @@ -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 isSetUp() async => (await _store.read(_entropyName)) != null; + + /// 처음 한 번: 새 복구 코드를 만들어 기기에 맡기고, 사용자에게 보여 줄 12단어를 돌려준다. + Future setUpNew() async { + final phrase = newRecoveryPhrase(); + await _store.write(_entropyName, phraseToEntropyHex(phrase)); + return phrase; + } + + /// 새 기기에서 12단어로 복구한다. 코드가 올바르지 않으면 false. + Future restore(String phrase) async { + if (!isValidRecoveryPhrase(phrase)) return false; + await _store.write(_entropyName, phraseToEntropyHex(phrase)); + return true; + } + + /// 설정에서 복구 코드를 다시 확인할 때. 아직 만들지 않았으면 null. + Future showPhrase() async { + final hex = await _store.read(_entropyName); + return hex == null ? null : entropyHexToPhrase(hex); + } + + /// 기본 비밀에서 [label] 용도의 값을 만든다 (신원 키, 보관함 비밀값 등). 기본 비밀 자체는 내주지 않는다. + Future> derive(String label) async { + final hex = await _store.read(_entropyName); + if (hex == null) { + throw StateError('키가 아직 없습니다. setUpNew 또는 restore 를 먼저 하세요.'); + } + return deriveKeyBytes(hexToBytes(hex), label); + } + + /// 보관함 서버에서 내 우편함을 열 때 내는 비밀값. + Future mailboxToken() async => bytesToHex(await derive(_mailboxLabel)); + + @override + Future databasePassword() async => bytesToHex(await derive(_databaseLabel)); +} diff --git a/lib/modules/keys/src/recovery_phrase.dart b/lib/modules/keys/src/recovery_phrase.dart new file mode 100644 index 0000000..ceb87cf --- /dev/null +++ b/lib/modules/keys/src/recovery_phrase.dart @@ -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); diff --git a/lib/modules/keys/src/recovery_shares.dart b/lib/modules/keys/src/recovery_shares.dart new file mode 100644 index 0000000..ad3bc6f --- /dev/null +++ b/lib/modules/keys/src/recovery_shares.dart @@ -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 _tag(List entropy) async => + bytesToHex((await Sha256().hash(entropy)).bytes.sublist(0, 2)); + + /// [phrase] 를 [n] 조각으로 나눈다. 그중 [k] 조각이 모이면 되살릴 수 있다. + static Future> 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 combine(Iterable 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 = { + 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; + } + } +} diff --git a/lib/modules/keys/src/shamir.dart b/lib/modules/keys/src/shamir.dart new file mode 100644 index 0000000..7a6ef7a --- /dev/null +++ b/lib/modules/keys/src/shamir.dart @@ -0,0 +1,91 @@ +import 'dart:math'; +import 'dart:typed_data'; + +/// 비밀을 [n] 조각으로 나누고, 그중 [k] 조각만 모이면 되살리는 방법 (Shamir, GF(256)). +/// k-1 조각으로는 비밀에 대해 아무것도 알 수 없다. 이 파일은 암호 라이브러리 없이 순수 계산만 한다. +class Shamir { + Shamir._(); + + static final List _exp = List.filled(512, 0); + static final List _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 split(List 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 combine(List shares) { + _init(); + if (shares.isEmpty) return const []; + final len = shares.first.data.length; + final out = List.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)]), + ); + } +} diff --git a/lib/modules/keys/src/shared_cipher.dart b/lib/modules/keys/src/shared_cipher.dart new file mode 100644 index 0000000..9807192 --- /dev/null +++ b/lib/modules/keys/src/shared_cipher.dart @@ -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 _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 a, List 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 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 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; // 키가 다르거나 중간에 바뀜 + } + } +} diff --git a/lib/modules/kinship/kinship.dart b/lib/modules/kinship/kinship.dart new file mode 100644 index 0000000..0c21d60 --- /dev/null +++ b/lib/modules/kinship/kinship.dart @@ -0,0 +1,8 @@ +/// 가족 관계: 가계도(부모-자식·배우자)로 두 사람의 촌수와 호칭을 계산한다. +/// +/// 순수 계산만 한다(저장·화면 없음). 어떤 모듈도 쓰지 않는다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/graph.dart'; +export 'src/relation.dart' show Relation, relate; diff --git a/lib/modules/kinship/src/graph.dart b/lib/modules/kinship/src/graph.dart new file mode 100644 index 0000000..ef10157 --- /dev/null +++ b/lib/modules/kinship/src/graph.dart @@ -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 toJson() => {'id': id, 'g': gender.name, if (birthYear != null) 'y': birthYear}; + + factory KinPerson.fromJson(Map 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 _people = {}; + final Map> _parents = {}; // 자식 → 부모들 + final Map> _children = {}; // 부모 → 자식들 + final Map> _spouses = {}; + + Iterable get people => _people.values; + KinPerson? person(String id) => _people[id]; + bool has(String id) => _people.containsKey(id); + + Set parentsOf(String id) => _parents[id] ?? const {}; + Set childrenOf(String id) => _children[id] ?? const {}; + Set 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 = {}; + 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(); + for (final p in (j['people'] as List).cast()) { + g.put(KinPerson.fromJson(p.cast())); + } + for (final e in (j['parent'] as List).cast()) { + g.addParent(e[0] as String, e[1] as String); + } + for (final e in (j['spouse'] as List).cast()) { + g.addSpouse(e[0] as String, e[1] as String); + } + } catch (_) { + return KinGraph(); // 깨진 데이터는 빈 가계도 + } + return g; + } +} diff --git a/lib/modules/kinship/src/relation.dart b/lib/modules/kinship/src/relation.dart new file mode 100644 index 0000000..e5d7a94 --- /dev/null +++ b/lib/modules/kinship/src/relation.dart @@ -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 _ancestors(KinGraph g, String id) { + final out = {id: const _Anc(0, null)}; + var frontier = [id]; + var d = 0; + while (frontier.isNotEmpty) { + d++; + final next = []; + 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); +} diff --git a/lib/modules/kinship/src/titles.dart b/lib/modules/kinship/src/titles.dart new file mode 100644 index 0000000..45cff4c --- /dev/null +++ b/lib/modules/kinship/src/titles.dart @@ -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}'; +} diff --git a/lib/modules/layout/layout.dart b/lib/modules/layout/layout.dart new file mode 100644 index 0000000..a332e8c --- /dev/null +++ b/lib/modules/layout/layout.dart @@ -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'; diff --git a/lib/modules/layout/src/card_template.dart b/lib/modules/layout/src/card_template.dart new file mode 100644 index 0000000..42620cd --- /dev/null +++ b/lib/modules/layout/src/card_template.dart @@ -0,0 +1,103 @@ +import 'package:friendly_map/modules/core/core.dart'; + +import 'ordered_store.dart'; +import 'text_store.dart'; + +/// 명함에 넣을 수 있는 항목 하나. +class FieldDef { + const FieldDef(this.key, this.label, {this.hint = ''}); + + final String key; + final String label; + final String hint; + + Map toJson() => {'key': key, 'label': label, 'hint': hint}; + + factory FieldDef.fromJson(Map 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 fields; + + CardTemplate copyWith({String? title, List? fields}) => + CardTemplate(id: id, kind: kind, title: title ?? this.title, fields: fields ?? this.fields); + + Map toJson() => + {'id': id, 'kind': kind, 'title': title, 'fields': [for (final f in fields) f.toJson()]}; + + factory CardTemplate.fromJson(Map 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())], + ); +} + +/// 명함 틀에 넣을 수 있는 알려진 항목들 (유추에 쓰이는 이름이라 틀을 고쳐도 "어디서 봤지?"가 계속 동작한다). +List 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 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 cardTemplateStore(TextStore store) => OrderedStore( + store: store, + key: 'layout_card_templates', + defaults: defaultCardTemplates, + toJson: (t) => t.toJson(), + fromJson: CardTemplate.fromJson, + idOf: (t) => t.id, + ); diff --git a/lib/modules/layout/src/home_item.dart b/lib/modules/layout/src/home_item.dart new file mode 100644 index 0000000..ae40105 --- /dev/null +++ b/lib/modules/layout/src/home_item.dart @@ -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 toJson() => {'id': id, 'title': title, 'visible': visible}; + + factory HomeItem.fromJson(Map m) => HomeItem( + id: m['id'] as String, + title: m['title'] as String, + visible: (m['visible'] as bool?) ?? true, + ); +} + +List 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 homeLayoutStore(TextStore store) => OrderedStore( + 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'], + ); diff --git a/lib/modules/layout/src/ordered_store.dart b/lib/modules/layout/src/ordered_store.dart new file mode 100644 index 0000000..9e3ba7e --- /dev/null +++ b/lib/modules/layout/src/ordered_store.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'text_store.dart'; + +/// 사용자가 지우고, 고치고, 순서를 바꿀 수 있는 목록을 저장하는 공통 도구. +/// 명함 템플릿과 홈 구성이 모두 이것으로 움직인다. 처음엔 [defaults] 로 시작하고, +/// 사용자가 바꾼 뒤에는 바뀐 목록을 쓴다. [reset] 으로 언제든 처음으로 되돌린다. +class OrderedStore { + 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 Function() defaults; + final Map Function(T) toJson; + final T Function(Map) fromJson; + final String Function(T) idOf; + + /// 이 장치가 "새 기본 항목 알아채기"를 쓰기 전에 이미 있던 기본 항목들의 ID. + /// 저장된 기록이 있는데 본 적 있는 목록이 없으면 이 ID 들은 이미 본 것으로 친다(지운 항목이 되살아나지 않게). + final List legacyIds; + + String get _seenKey => '$key.seen'; + + Future> 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()), + ]; + return await _withNewDefaults(list); + } catch (_) { + return List.of(defaults()); // 깨졌으면 처음 상태로 + } + } + + /// 앱이 업데이트되며 생긴 새 기본 항목을, 아직 본 적 없는 것만 알맞은 자리에 끼운다. + /// 사용자가 지운 항목은 이미 본 것이라 되살아나지 않는다. + Future> _withNewDefaults(List 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().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 save(List 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> reset() async { + final d = List.of(defaults()); + await save(d); + return d; + } + + /// 같은 ID 가 있으면 바꾸고, 없으면 끝에 붙인다. + Future> 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> remove(String id) async { + final list = (await load())..removeWhere((e) => idOf(e) == id); + await save(list); + return list; + } + + /// [from] 번째를 [to] 번째 자리로 옮긴다 (목록 끝을 넘으면 끝으로). + Future> 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; + } +} diff --git a/lib/modules/layout/src/text_store.dart b/lib/modules/layout/src/text_store.dart new file mode 100644 index 0000000..740e5ea --- /dev/null +++ b/lib/modules/layout/src/text_store.dart @@ -0,0 +1,16 @@ +/// 글자 하나를 이름으로 저장·읽는 약속. 배치 모듈은 저장소를 모르고 이 약속만 쓴다. +abstract class TextStore { + Future read(String key); + Future write(String key, String value); +} + +/// 메모리에만 두는 구현 (시험·미리보기용). +class MemoryTextStore implements TextStore { + final Map data = {}; + + @override + Future read(String key) async => data[key]; + + @override + Future write(String key, String value) async => data[key] = value; +} diff --git a/lib/modules/platform/platform.dart b/lib/modules/platform/platform.dart new file mode 100644 index 0000000..2d2f727 --- /dev/null +++ b/lib/modules/platform/platform.dart @@ -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'; diff --git a/lib/modules/platform/platform_ui.dart b/lib/modules/platform/platform_ui.dart new file mode 100644 index 0000000..d8efb6e --- /dev/null +++ b/lib/modules/platform/platform_ui.dart @@ -0,0 +1,6 @@ +/// 플랫폼: 화면 부품이 필요한 약속 (Flutter 가 필요한 부분만 따로 둔다). +/// +/// 화면(ui) 모듈에서만 import 한다. +library; + +export 'src/qr_scanner_view.dart'; diff --git a/lib/modules/platform/src/call_log_provider.dart b/lib/modules/platform/src/call_log_provider.dart new file mode 100644 index 0000000..fe117aa --- /dev/null +++ b/lib/modules/platform/src/call_log_provider.dart @@ -0,0 +1,22 @@ +enum CallKind { incoming, outgoing, missed } + +/// 기기 통화 기록 한 줄. +class CallLogEntry { + const CallLogEntry(this.number, this.at, this.seconds, this.kind); + final String number; + final DateTime at; + final int seconds; + final CallKind kind; +} + +/// 기기의 통화 기록. 권한이 없으면 빈 목록. +abstract class CallLogProvider { + /// [number] 와 같은 번호(끝 8자리 기준)의 기록, 최근 것부터. + Future> forNumber(String number, {int limit = 200}); +} + +/// 전화 걸기 · 문자 보내기 화면 띄우기. +abstract class PhoneActions { + Future dial(String number); + Future sms(String number); +} diff --git a/lib/modules/platform/src/device_storage_probe.dart b/lib/modules/platform/src/device_storage_probe.dart new file mode 100644 index 0000000..bee7dea --- /dev/null +++ b/lib/modules/platform/src/device_storage_probe.dart @@ -0,0 +1,4 @@ +/// 기기의 저장 공간 사용률(%)을 알려 주는 도구. 구현은 플랫폼 쪽에서 붙인다. +abstract class DeviceStorageProbe { + Future usedPercent(); +} diff --git a/lib/modules/platform/src/lan_discovery.dart b/lib/modules/platform/src/lan_discovery.dart new file mode 100644 index 0000000..3022214 --- /dev/null +++ b/lib/modules/platform/src/lan_discovery.dart @@ -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 advertise({required String phone, required int port}); + + /// 같은 네트워크에서 발견되는 상대들. + Stream get peers; + + Future start(); + Future stop(); +} diff --git a/lib/modules/platform/src/location_provider.dart b/lib/modules/platform/src/location_provider.dart new file mode 100644 index 0000000..a0b4cee --- /dev/null +++ b/lib/modules/platform/src/location_provider.dart @@ -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 current(); +} diff --git a/lib/modules/platform/src/motion_sensor.dart b/lib/modules/platform/src/motion_sensor.dart new file mode 100644 index 0000000..a6b485d --- /dev/null +++ b/lib/modules/platform/src/motion_sensor.dart @@ -0,0 +1,5 @@ +/// 기기의 가속도 센서. 흔들기 감지에 쓴다 (폰 중심 기능). +abstract class MotionSensor { + /// 가속도 크기(중력 포함, m/s²)를 계속 내보낸다. 가만히 있으면 약 9.8. + Stream get accelerationMagnitude; +} diff --git a/lib/modules/platform/src/notifier.dart b/lib/modules/platform/src/notifier.dart new file mode 100644 index 0000000..7345ac2 --- /dev/null +++ b/lib/modules/platform/src/notifier.dart @@ -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 replaceAll(List notices); +} diff --git a/lib/modules/platform/src/qr_scanner_view.dart b/lib/modules/platform/src/qr_scanner_view.dart new file mode 100644 index 0000000..3a05dcc --- /dev/null +++ b/lib/modules/platform/src/qr_scanner_view.dart @@ -0,0 +1,7 @@ +import 'package:flutter/widgets.dart'; + +/// QR 코드를 읽는 화면 부품을 만들어 주는 약속 (카메라 사용, 플랫폼마다 다름). +abstract class QrScannerViewFactory { + /// 코드를 읽으면 [onCode] 로 글자를 넘기는 부품. + Widget build({required void Function(String code) onCode}); +} diff --git a/lib/modules/platform/src/secure_key_store.dart b/lib/modules/platform/src/secure_key_store.dart new file mode 100644 index 0000000..38ca4d2 --- /dev/null +++ b/lib/modules/platform/src/secure_key_store.dart @@ -0,0 +1,8 @@ +/// 기기 보안 저장소 (안드로이드 Keystore, iOS Keychain 등). 작은 비밀값을 이름으로 맡긴다. +/// +/// 플랫폼마다 구현이 다르므로 다른 모듈은 이 약속(인터페이스)만 쓴다. +abstract class SecureKeyStore { + Future read(String name); + Future write(String name, String value); + Future delete(String name); +} diff --git a/lib/modules/platform_android/platform_android.dart b/lib/modules/platform_android/platform_android.dart new file mode 100644 index 0000000..71bc4d8 --- /dev/null +++ b/lib/modules/platform_android/platform_android.dart @@ -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'; diff --git a/lib/modules/platform_android/src/android_call_log.dart b/lib/modules/platform_android/src/android_call_log.dart new file mode 100644 index 0000000..ecf2e9c --- /dev/null +++ b/lib/modules/platform_android/src/android_call_log.dart @@ -0,0 +1,51 @@ +import 'package:call_log/call_log.dart' as cl; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:url_launcher/url_launcher.dart'; + +String _tail(String n) { + final d = n.replaceAll(RegExp(r'[^0-9]'), ''); + return d.length <= 8 ? d : d.substring(d.length - 8); +} + +/// 안드로이드 통화 기록 (READ_CALL_LOG 권한 필요. 거절되면 빈 목록). +class AndroidCallLog implements CallLogProvider { + @override + Future> 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 = []; + 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 dial(String number) => _open(Uri(scheme: 'tel', path: number)); + + @override + Future sms(String number) => _open(Uri(scheme: 'sms', path: number)); + + Future _open(Uri u) async { + try { + return await launchUrl(u); + } catch (_) { + return false; + } + } +} diff --git a/lib/modules/platform_android/src/android_database.dart b/lib/modules/platform_android/src/android_database.dart new file mode 100644 index 0000000..6d9dbae --- /dev/null +++ b/lib/modules/platform_android/src/android_database.dart @@ -0,0 +1,12 @@ +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/storage/storage_sqlite.dart'; +import 'package:sqflite_common/sqlite_api.dart' show Database; +import 'package:sqflite_sqlcipher/sqflite.dart' as cipher; + +/// 암호화된 SQLite(SQLCipher)를 열어 저장소를 만든다. 암호는 [KeyProvider] 가 내준다. +Future openEncryptedStorage(KeyProvider keys, {String name = 'friendly_map.db'}) async { + final dir = await cipher.getDatabasesPath(); + final Database db = await cipher.openDatabase('$dir/$name', password: await keys.databasePassword()); + return openSqliteStorage(db); +} diff --git a/lib/modules/platform_android/src/android_lan_discovery.dart b/lib/modules/platform_android/src/android_lan_discovery.dart new file mode 100644 index 0000000..2677258 --- /dev/null +++ b/lib/modules/platform_android/src/android_lan_discovery.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:nsd/nsd.dart'; + +/// 같은 Wi-Fi 의 상대 찾기 (안드로이드 NSD). 서비스 이름표에 내 전화번호를 담아 알린다. +class AndroidLanDiscovery implements LanDiscovery { + static const _type = '_friendlymap._tcp'; + + final StreamController _peers = StreamController.broadcast(); + Registration? _registration; + Discovery? _discovery; + String? _phone; + int? _port; + + @override + Stream get peers => _peers.stream; + + @override + Future advertise({required String phone, required int port}) async { + _phone = phone; + _port = port; + } + + @override + Future start() async { + final phone = _phone, port = _port; + if (phone != null && port != null && port > 0 && _registration == null) { + _registration = await register(Service( + name: 'fm-${DateTime.now().microsecondsSinceEpoch}', + type: _type, + port: port, + txt: {'p': Uint8List.fromList(utf8.encode(phone))}, + )); + } + _discovery ??= await startDiscovery(_type, ipLookupType: IpLookupType.v4); + _discovery!.addServiceListener((service, status) { + if (status != ServiceStatus.found) return; + final raw = service.txt?['p']; + final servicePort = service.port; + final addr = (service.addresses?.isNotEmpty ?? false) ? service.addresses!.first.address : service.host; + if (raw == null || servicePort == null || addr == null) return; + _peers.add(LanPeer(phone: utf8.decode(raw), host: addr, port: servicePort)); + }); + } + + @override + Future stop() async { + final d = _discovery, r = _registration; + _discovery = null; + _registration = null; + if (d != null) await stopDiscovery(d); + if (r != null) await unregister(r); + } +} diff --git a/lib/modules/platform_android/src/android_location_provider.dart b/lib/modules/platform_android/src/android_location_provider.dart new file mode 100644 index 0000000..d63960d --- /dev/null +++ b/lib/modules/platform_android/src/android_location_provider.dart @@ -0,0 +1,21 @@ +import 'package:geolocator/geolocator.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; + +/// 안드로이드 위치. 권한이 없으면 한 번 물어보고, 거절되거나 위치가 꺼져 있으면 null. +class AndroidLocationProvider implements LocationProvider { + @override + Future current() async { + try { + if (!await Geolocator.isLocationServiceEnabled()) return null; + var perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.denied) perm = await Geolocator.requestPermission(); + if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) return null; + final p = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, timeLimit: Duration(seconds: 10)), + ); + return GeoPoint(p.latitude, p.longitude); + } catch (_) { + return null; + } + } +} diff --git a/lib/modules/platform_android/src/android_motion_sensor.dart b/lib/modules/platform_android/src/android_motion_sensor.dart new file mode 100644 index 0000000..0be2601 --- /dev/null +++ b/lib/modules/platform_android/src/android_motion_sensor.dart @@ -0,0 +1,11 @@ +import 'dart:math'; + +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:sensors_plus/sensors_plus.dart'; + +/// 안드로이드 가속도 센서. +class AndroidMotionSensor implements MotionSensor { + @override + Stream get accelerationMagnitude => accelerometerEventStream() + .map((e) => sqrt(e.x * e.x + e.y * e.y + e.z * e.z)); +} diff --git a/lib/modules/platform_android/src/android_notifier.dart b/lib/modules/platform_android/src/android_notifier.dart new file mode 100644 index 0000000..a76647d --- /dev/null +++ b/lib/modules/platform_android/src/android_notifier.dart @@ -0,0 +1,40 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:timezone/data/latest.dart' as tzdata; +import 'package:timezone/timezone.dart' as tz; + +/// 안드로이드 예약 알림. 시각은 순간(instant) 그대로 쓰므로 기기 시간대와 무관하다. +class AndroidNotifier implements Notifier { + final _plugin = FlutterLocalNotificationsPlugin(); + bool _ready = false; + + Future _init() async { + if (_ready) return; + tzdata.initializeTimeZones(); + await _plugin.initialize(const InitializationSettings(android: AndroidInitializationSettings('@mipmap/ic_launcher'))); + final a = _plugin.resolvePlatformSpecificImplementation(); + await a?.requestNotificationsPermission(); + _ready = true; + } + + @override + Future replaceAll(List notices) async { + try { + await _init(); + await _plugin.cancelAll(); + var id = 1; + for (final n in notices.take(60)) { + await _plugin.zonedSchedule( + id++, + '프렌들리맵', + n.text, + tz.TZDateTime.from(n.fireAt, tz.UTC), + const NotificationDetails( + android: AndroidNotificationDetails('events', '가족 일정', channelDescription: '생일·기일 알림')), + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime, + ); + } + } catch (_) {} + } +} diff --git a/lib/modules/platform_android/src/android_qr_scanner.dart b/lib/modules/platform_android/src/android_qr_scanner.dart new file mode 100644 index 0000000..4d84027 --- /dev/null +++ b/lib/modules/platform_android/src/android_qr_scanner.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'package:friendly_map/modules/platform/platform_ui.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// 카메라로 QR 을 읽는 화면 부품 (안드로이드). +class AndroidQrScannerFactory implements QrScannerViewFactory { + @override + Widget build({required void Function(String code) onCode}) { + return MobileScanner( + onDetect: (capture) { + for (final b in capture.barcodes) { + final v = b.rawValue; + if (v != null && v.isNotEmpty) { + onCode(v); + return; + } + } + }, + ); + } +} diff --git a/lib/modules/platform_android/src/android_secure_key_store.dart b/lib/modules/platform_android/src/android_secure_key_store.dart new file mode 100644 index 0000000..e59122f --- /dev/null +++ b/lib/modules/platform_android/src/android_secure_key_store.dart @@ -0,0 +1,19 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; + +/// 안드로이드 보안 저장소(Keystore)에 비밀값을 맡긴다. +class AndroidSecureKeyStore implements SecureKeyStore { + AndroidSecureKeyStore([FlutterSecureStorage? storage]) + : _storage = storage ?? const FlutterSecureStorage(); + + final FlutterSecureStorage _storage; + + @override + Future read(String name) => _storage.read(key: name); + + @override + Future write(String name, String value) => _storage.write(key: name, value: value); + + @override + Future delete(String name) => _storage.delete(key: name); +} diff --git a/lib/modules/platform_android/src/android_storage_probe.dart b/lib/modules/platform_android/src/android_storage_probe.dart new file mode 100644 index 0000000..9b09cf9 --- /dev/null +++ b/lib/modules/platform_android/src/android_storage_probe.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:friendly_map/modules/platform/platform.dart'; + +import 'df_parser.dart'; + +/// 기기 저장 공간 사용률. 안드로이드의 `df` 명령 결과를 읽는다. 읽지 못하면 0%로 보고 권고를 하지 않는다. +class AndroidStorageProbe implements DeviceStorageProbe { + AndroidStorageProbe({this.path = '/data'}); + + final String path; + + @override + Future usedPercent() async { + try { + final r = await Process.run('df', ['-k', path]); + if (r.exitCode != 0) return 0; + return parseDfUsedPercent('${r.stdout}') ?? 0; + } catch (_) { + return 0; + } + } +} diff --git a/lib/modules/platform_android/src/df_parser.dart b/lib/modules/platform_android/src/df_parser.dart new file mode 100644 index 0000000..9590a90 --- /dev/null +++ b/lib/modules/platform_android/src/df_parser.dart @@ -0,0 +1,13 @@ +/// `df -k <경로>` 의 출력에서 사용률(%)을 읽는다. 읽을 수 없으면 null. +/// 플러그인 없이 순수 계산이라 컴퓨터에서 바로 시험된다. +int? parseDfUsedPercent(String output) { + final lines = output.split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList(); + if (lines.length < 2) return null; + for (final token in lines.last.split(RegExp(r'\s+'))) { + if (token.endsWith('%')) { + final n = int.tryParse(token.substring(0, token.length - 1)); + if (n != null && n >= 0 && n <= 100) return n; + } + } + return null; +} diff --git a/lib/modules/sharing/sharing.dart b/lib/modules/sharing/sharing.dart new file mode 100644 index 0000000..f530d2a --- /dev/null +++ b/lib/modules/sharing/sharing.dart @@ -0,0 +1,7 @@ +/// 공유: 명함 장·대화방 단위의 복제 범위 관리 +/// +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/share_service.dart'; +export 'src/share_status.dart'; diff --git a/lib/modules/sharing/src/share_service.dart b/lib/modules/sharing/src/share_service.dart new file mode 100644 index 0000000..34a4447 --- /dev/null +++ b/lib/modules/sharing/src/share_service.dart @@ -0,0 +1,115 @@ +import 'package:friendly_map/modules/storage/storage.dart'; + +import 'share_status.dart'; + +/// 상대에게 보낼 명함 내용을 정하는 함수 (예: 공개 범위에 맞게 거르기). 없으면 명함 내용 그대로. +typedef SnapshotFilter = Future Function(MyCard card, String peerId); + +/// 명함 장·대화방 단위의 공유 범위를 관리한다. +/// +/// - 명함은 공유한 순간의 내용을 상대에게 보내고, 고친 내용은 [resend] 로 다시 보낸다. +/// - 대화방은 공유를 시작한 뒤의 대화가 참여자에게 간다. +/// - 개인 대화는 명함 없이 되고, 그 밖의 맥락은 관련 명함을 공유해야 한다. +class ShareService { + ShareService(this._storage, this._clock, {SnapshotFilter? filter}) : _filter = filter; + + final Storage _storage; + final Clock _clock; + final SnapshotFilter? _filter; + + Future _contentFor(MyCard card, String peerId) async { + final f = _filter; + return f == null ? card.fieldsJson : await f(card, peerId); + } + + /// 명함 한 장을 상대에게 공유한다. 이미 공유 중이면 그 기록을 돌려준다. + /// 명함이 없으면 null. + Future shareCard(String cardId, String peerId) async { + final card = await _storage.myCards.get(cardId); + if (card == null) return null; + final existing = await _activeShare(cardId, peerId); + if (existing != null) return existing; + final now = _clock.now(); + final record = ShareRecord( + id: newId(), + unit: 'card', + targetId: cardId, + peerId: peerId, + startedAt: now, + snapshot: await _contentFor(card, peerId), + sentVersion: card.updatedAt, + updatedAt: now, + ); + await _storage.shares.put(record); + return record; + } + + /// 대화방을 상대와 공유한다 (그 방의 이후 대화가 상대에게 간다). + Future shareRoom(String roomId, String peerId) async { + final room = await _storage.rooms.get(roomId); + if (room == null) return null; + final existing = await _activeShare(roomId, peerId); + if (existing != null) return existing; + final now = _clock.now(); + final record = ShareRecord( + id: newId(), + unit: 'room', + targetId: roomId, + peerId: peerId, + startedAt: now, + updatedAt: now, + ); + await _storage.shares.put(record); + return record; + } + + /// 고친 명함 내용을 다시 보낼 준비를 한다. 보낼 기록을 돌려주고, 전송은 전송 모듈이 한다. + /// 공유 중인 명함 공유가 아니면 null. + Future resend(String shareId) async { + final share = await _storage.shares.get(shareId); + if (share == null || !share.active || share.unit != 'card') return null; + final card = await _storage.myCards.get(share.targetId); + if (card == null) return null; + final updated = share.resent( + snapshot: await _contentFor(card, share.peerId), + sentVersion: card.updatedAt, + updatedAt: _clock.now(), + ); + await _storage.shares.put(updated); + return updated; + } + + /// 공유를 끝낸다. 이미 상대 기기에 복제된 내용은 남는다. + Future stop(String shareId) => _storage.shares.end(shareId, _clock.now()); + + /// 공유 뒤에 명함을 고쳐서 다시 보내야 하는 공유들. + Future> needingResend() async { + final result = []; + for (final s in await _storage.shares.activeOf('card')) { + final card = await _storage.myCards.get(s.targetId); + if (shareStatusOf(s, card?.updatedAt) == ShareStatus.needsResend) { + result.add(s); + } + } + return result; + } + + /// 이 상대와 [contextKind] 맥락의 대화방을 열 수 있는지. + /// 개인은 명함 없이 열 수 있고, 그 밖의 맥락은 같은 종류의 명함을 공유 중이어야 한다. + Future canStartContextRoom(String contextKind, String peerId) async { + if (contextKind == 'personal') return true; + for (final s in await _storage.shares.activeOf('card')) { + if (s.peerId != peerId) continue; + final card = await _storage.myCards.get(s.targetId); + if (card != null && card.kind == contextKind) return true; + } + return false; + } + + Future _activeShare(String targetId, String peerId) async { + for (final s in await _storage.shares.activeFor(targetId)) { + if (s.peerId == peerId) return s; + } + return null; + } +} diff --git a/lib/modules/sharing/src/share_status.dart b/lib/modules/sharing/src/share_status.dart new file mode 100644 index 0000000..5f50eaa --- /dev/null +++ b/lib/modules/sharing/src/share_status.dart @@ -0,0 +1,23 @@ +import 'package:friendly_map/modules/storage/storage.dart'; + +/// 공유 상태. +enum ShareStatus { + /// 상대가 가진 내용이 지금 내 명함과 같다. + upToDate, + + /// 공유 뒤에 내가 명함을 고쳤다. 내가 "다시 보내기"를 눌러야 상대에게 간다. + needsResend, + + /// 공유를 끝냈다 (이미 상대 기기에 복제된 내용은 남는다). + ended, +} + +/// 공유 기록과 내 명함의 현재 변경 시각으로 상태를 정한다. +/// 명함을 고쳐도 상대에게는 자동으로 가지 않는다 (사용자가 다시 보내야 함). +ShareStatus shareStatusOf(ShareRecord share, int? cardUpdatedAt) { + if (!share.active) return ShareStatus.ended; + if (share.unit != 'card') return ShareStatus.upToDate; + final sent = share.sentVersion; + if (cardUpdatedAt == null || sent == null) return ShareStatus.upToDate; + return cardUpdatedAt > sent ? ShareStatus.needsResend : ShareStatus.upToDate; +} diff --git a/lib/modules/storage/src/archive_advice.dart b/lib/modules/storage/src/archive_advice.dart new file mode 100644 index 0000000..8d42813 --- /dev/null +++ b/lib/modules/storage/src/archive_advice.dart @@ -0,0 +1,35 @@ +import 'models/app_settings.dart'; + +/// 별도 저장을 권할지에 대한 판단 결과. 나눌 범위는 사용자가 직접 고른다. +class ArchiveAdvice { + const ArchiveAdvice({ + required this.textBytes, + required this.usedPercent, + required this.overText, + required this.overDisk, + }); + + final int textBytes; + final int usedPercent; + + /// 이 방의 텍스트가 설정 기준(기본 1MB)을 넘었다. + final bool overText; + + /// 기기 저장 공간이 설정 기준(기본 80%) 이상 찼다. + final bool overDisk; + + bool get recommended => overText || overDisk; +} + +/// 설정 기준과 비교해 별도 저장을 권할지 정한다 (바로 나누지는 않는다). +ArchiveAdvice adviseArchive({ + required int textBytes, + required int usedPercent, + required AppSettings settings, +}) => + ArchiveAdvice( + textBytes: textBytes, + usedPercent: usedPercent, + overText: textBytes > settings.separateTextBytes, + overDisk: usedPercent >= settings.storageUsagePercent, + ); diff --git a/lib/modules/storage/src/archive_service.dart b/lib/modules/storage/src/archive_service.dart new file mode 100644 index 0000000..e45fea0 --- /dev/null +++ b/lib/modules/storage/src/archive_service.dart @@ -0,0 +1,43 @@ +import 'package:friendly_map/modules/platform/platform.dart'; + +import 'archive_advice.dart'; +import 'clock.dart'; +import 'repos/interfaces.dart'; +import 'settings_store.dart'; + +/// 대화가 많을 때 별도 저장을 권하고, 사용자가 고른 범위를 별도 저장으로 나눈다. +/// 지우지 않고 표시만 바꾸므로 언제든 되돌릴 수 있다. +class ArchiveService { + ArchiveService({ + required MessageRepo messages, + required SettingsStore settings, + required Clock clock, + required DeviceStorageProbe probe, + }) : _messages = messages, + _settings = settings, + _clock = clock, + _probe = probe; + + final MessageRepo _messages; + final SettingsStore _settings; + final Clock _clock; + final DeviceStorageProbe _probe; + + /// 한 방에 대해 별도 저장을 권할지 본다. + Future adviseRoom(String roomId) async => adviseArchive( + textBytes: await _messages.textBytes(roomId), + usedPercent: await _probe.usedPercent(), + settings: await _settings.load(), + ); + + /// 사용자가 고를 수 있는 전체 범위 (아직 나누지 않은 대화의 처음~끝). 대화가 없으면 null. + Future<(int, int)?> selectableSpan(String roomId) => _messages.span(roomId); + + /// 고른 범위의 대화를 별도 저장으로 나눈다. 나눈 건수를 돌려준다. + Future archiveRange(String roomId, int from, int to) => + _messages.setArchivedBetween(roomId, from, to, _clock.now()); + + /// 고른 범위의 대화를 일반 저장으로 되돌린다. + Future restoreRange(String roomId, int from, int to) => + _messages.setArchivedBetween(roomId, from, to, _clock.now(), archived: false); +} diff --git a/lib/modules/storage/src/clock.dart b/lib/modules/storage/src/clock.dart new file mode 100644 index 0000000..902476c --- /dev/null +++ b/lib/modules/storage/src/clock.dart @@ -0,0 +1,12 @@ +/// 모든 변경에는 1/100초 단위 시각을 기록한다 (먼저 쓴 변경이 앞서는 규칙의 바탕). +/// +/// 기기 시계 오차는 동기화 모듈이 [Clock.offset] 으로 보정해 넣는다. +class Clock { + Clock([this.offset = 0]); + + /// 서버 또는 상대 기기와 맞춘 오차 (1/100초 단위, 더하는 값). + int offset; + + /// 지금 시각 (1970년부터 지난 1/100초). + int now() => DateTime.now().millisecondsSinceEpoch ~/ 10 + offset; +} diff --git a/lib/modules/storage/src/ids.dart b/lib/modules/storage/src/ids.dart new file mode 100644 index 0000000..7ec90e8 --- /dev/null +++ b/lib/modules/storage/src/ids.dart @@ -0,0 +1,12 @@ +import 'dart:math'; + +final Random _rng = Random.secure(); + +/// 기기마다 겹치지 않는 128비트 임의 ID (32자 16진수). +String newId() { + final b = StringBuffer(); + for (var i = 0; i < 16; i++) { + b.write(_rng.nextInt(256).toRadixString(16).padLeft(2, '0')); + } + return b.toString(); +} diff --git a/lib/modules/storage/src/memory/memory_cards.dart b/lib/modules/storage/src/memory/memory_cards.dart new file mode 100644 index 0000000..00b860b --- /dev/null +++ b/lib/modules/storage/src/memory/memory_cards.dart @@ -0,0 +1,24 @@ +import '../models/link.dart'; +import '../models/my_card.dart'; +import '../models/received_card.dart'; +import '../repos/interfaces.dart'; +import 'memory_repo.dart'; + +class MemoryMyCardRepo extends MemoryRepo implements MyCardRepo { + @override + Future> byKind(String kind) async => + rows.values.where((c) => c.kind == kind).toList(); +} + +class MemoryReceivedCardRepo extends MemoryRepo implements ReceivedCardRepo { + @override + Future> byPerson(String personId) async => + rows.values.where((c) => c.personId == personId).toList() + ..sort((a, b) => b.receivedAt.compareTo(a.receivedAt)); +} + +class MemoryLinkRepo extends MemoryRepo implements LinkRepo { + @override + Future> byPerson(String personId) async => + rows.values.where((l) => l.personId == personId).toList(); +} diff --git a/lib/modules/storage/src/memory/memory_chat.dart b/lib/modules/storage/src/memory/memory_chat.dart new file mode 100644 index 0000000..4de78bc --- /dev/null +++ b/lib/modules/storage/src/memory/memory_chat.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; + +import '../models/chat_room.dart'; +import '../models/friend_request.dart'; +import '../models/message.dart'; +import '../models/share_record.dart'; +import '../repos/interfaces.dart'; +import 'memory_repo.dart'; + +class MemoryChatRoomRepo extends MemoryRepo implements ChatRoomRepo { + @override + Future> recent({bool includeArchived = false}) async => + rows.values.where((r) => includeArchived || !r.archived).toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); +} + +class MemoryMessageRepo extends MemoryRepo implements MessageRepo { + Iterable _live(String roomId) => + rows.values.where((m) => m.roomId == roomId && !m.archived); + + @override + Future> page(String roomId, {int? before, int limit = 50}) async { + final list = _live(roomId).where((m) => before == null || m.sentAt < before).toList() + ..sort((a, b) => b.sentAt.compareTo(a.sentAt)); + return list.take(limit).toList().reversed.toList(); + } + + @override + Future textBytes(String roomId) async => + _live(roomId).fold(0, (sum, m) => sum + utf8.encode(m.body).length); + + @override + Future<(int, int)?> span(String roomId) async { + final list = _live(roomId).toList(); + if (list.isEmpty) return null; + final times = list.map((m) => m.sentAt); + return (times.reduce((a, b) => a < b ? a : b), times.reduce((a, b) => a > b ? a : b)); + } + + @override + Future setArchivedBetween(String roomId, int from, int to, int stamp, + {bool archived = true}) async { + var n = 0; + for (final m in rows.values.toList()) { + if (m.roomId == roomId && m.archived != archived && m.sentAt >= from && m.sentAt <= to) { + rows[m.id] = m.withArchived(archived, stamp); + n++; + } + } + return n; + } +} + +class MemoryShareRepo extends MemoryRepo implements ShareRepo { + @override + Future> activeFor(String targetId) async => + rows.values.where((s) => s.targetId == targetId && s.active).toList(); + + @override + Future> activeOf(String unit) async => + rows.values.where((s) => s.unit == unit && s.active).toList(); + + @override + Future end(String id, int stamp) async { + final s = rows[id]; + if (s != null) rows[id] = s.endedAtStamp(stamp); + } +} + +class MemoryFriendRequestRepo extends MemoryRepo implements FriendRequestRepo { + @override + Future> pending() async => + rows.values.where((r) => r.status == 'pending').toList() + ..sort((a, b) => a.receivedAt.compareTo(b.receivedAt)); +} diff --git a/lib/modules/storage/src/memory/memory_misc.dart b/lib/modules/storage/src/memory/memory_misc.dart new file mode 100644 index 0000000..4ca544b --- /dev/null +++ b/lib/modules/storage/src/memory/memory_misc.dart @@ -0,0 +1,32 @@ +import '../models/outbox_item.dart'; +import '../repos/interfaces.dart'; + +class MemoryOutboxRepo implements OutboxRepo { + final Map _items = {}; + + @override + Future add(OutboxItem item) async => _items[item.id] = item; + + @override + Future> pending() async => + _items.values.toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + @override + Future update(OutboxItem item) async => _items[item.id] = item; + + @override + Future remove(String id) async => _items.remove(id); +} + +class MemoryKeyValueRepo implements KeyValueRepo { + final Map _m = {}; + + @override + Future get(String key) async => _m[key]; + + @override + Future put(String key, String value, int stamp) async => _m[key] = value; + + @override + Future> all() async => Map.of(_m); +} diff --git a/lib/modules/storage/src/memory/memory_people.dart b/lib/modules/storage/src/memory/memory_people.dart new file mode 100644 index 0000000..817a3b6 --- /dev/null +++ b/lib/modules/storage/src/memory/memory_people.dart @@ -0,0 +1,53 @@ +import '../models/person.dart'; +import '../repos/interfaces.dart'; +import 'memory_repo.dart'; + +class _Alias { + _Alias(this.personId, this.kind, this.value); + final String personId; + final String kind; + final String value; +} + +class MemoryPeopleRepo extends MemoryRepo implements PeopleRepo { + final List<_Alias> _aliases = []; + + @override + Future byPhone(String phone) async { + for (final p in rows.values) { + if (p.phone == phone) return p; + } + return null; + } + + @override + Future byOldPhone(String phone) async { + for (final a in _aliases) { + if (a.kind == 'phone' && a.value == phone) return rows[a.personId]; + } + return null; + } + + @override + Future> byName(String name) async => + rows.values.where((p) => p.name == name).toList(); + + @override + Future> byOldName(String name) async { + final ids = { + for (final a in _aliases) + if (a.kind == 'name' && a.value == name) a.personId, + }; + return [for (final id in ids) if (rows[id] != null) rows[id]!]; + } + + @override + Future> oldNames(String personId) async => { + for (final a in _aliases) + if (a.kind == 'name' && a.personId == personId) a.value, + }; + + @override + Future addAlias(String personId, String kind, String value, int stamp) async => + _aliases.add(_Alias(personId, kind, value)); +} diff --git a/lib/modules/storage/src/memory/memory_repo.dart b/lib/modules/storage/src/memory/memory_repo.dart new file mode 100644 index 0000000..94cc471 --- /dev/null +++ b/lib/modules/storage/src/memory/memory_repo.dart @@ -0,0 +1,22 @@ +import '../models/row.dart'; +import '../repos/interfaces.dart'; + +/// 메모리에만 두는 표. 테스트와 화면 미리보기에서 SQLite 대신 쓴다. +class MemoryRepo implements Repo { + final Map rows = {}; + + @override + Future put(T row) async => rows[row.id] = row; + + @override + Future get(String id) async => rows[id]; + + @override + Future remove(String id) async => rows.remove(id); + + @override + Future> all() async => rows.values.toList(); + + @override + Future updatedAtOf(String id) async => rows[id]?.updatedAt; +} diff --git a/lib/modules/storage/src/models/app_settings.dart b/lib/modules/storage/src/models/app_settings.dart new file mode 100644 index 0000000..fe14872 --- /dev/null +++ b/lib/modules/storage/src/models/app_settings.dart @@ -0,0 +1,42 @@ +/// 앱 설정. 표에는 키-값으로 저장하고, 없는 값은 기본값을 쓴다. +class AppSettings { + const AppSettings({ + this.separateTextBytes = defaultSeparateTextBytes, + this.storageUsagePercent = 80, + this.exchangeMode = 'request', + this.recoveryShares = 5, + this.recoveryThreshold = 3, + }); + + static const int defaultSeparateTextBytes = 1024 * 1024; // 텍스트 기준 1MB + + /// 대화방 하나의 텍스트가 이 크기를 넘으면 별도 저장소로 나눈다. + final int separateTextBytes; + + /// 기기 저장 공간이 이 비율(%) 이상 차면 별도 저장소로 나눈다. + final int storageUsagePercent; + + /// 명함 교환 방식: auto(자동 교환) | request(요청 후 상대 수락) + final String exchangeMode; + + /// 복구 조각 수와, 복구에 필요한 조각 수. + final int recoveryShares; + final int recoveryThreshold; + + factory AppSettings.fromMap(Map m) => AppSettings( + separateTextBytes: + int.tryParse(m['separate_text_bytes'] ?? '') ?? defaultSeparateTextBytes, + storageUsagePercent: int.tryParse(m['storage_usage_percent'] ?? '') ?? 80, + exchangeMode: m['exchange_mode'] ?? 'request', + recoveryShares: int.tryParse(m['recovery_shares'] ?? '') ?? 5, + recoveryThreshold: int.tryParse(m['recovery_threshold'] ?? '') ?? 3, + ); + + Map toMap() => { + 'separate_text_bytes': '$separateTextBytes', + 'storage_usage_percent': '$storageUsagePercent', + 'exchange_mode': exchangeMode, + 'recovery_shares': '$recoveryShares', + 'recovery_threshold': '$recoveryThreshold', + }; +} diff --git a/lib/modules/storage/src/models/chat_room.dart b/lib/modules/storage/src/models/chat_room.dart new file mode 100644 index 0000000..649c647 --- /dev/null +++ b/lib/modules/storage/src/models/chat_room.dart @@ -0,0 +1,45 @@ +import 'row.dart'; + +/// 대화방. 개인을 벗어난 맥락은 [contextKind] 에 해당 명함 종류를 둔다. +class ChatRoom implements Row { + const ChatRoom({ + required this.id, + required this.title, + required this.kind, + required this.contextKind, + required this.membersJson, + required this.updatedAt, + this.archived = false, + }); + + @override + final String id; + final String title; + final String kind; // direct | group + final String contextKind; // personal | company | school | group | family + final String membersJson; // 참여자(Person) id 목록 (JSON 배열 문자열) + final bool archived; // 별도 저장소로 나눈 방 + @override + final int updatedAt; + + factory ChatRoom.fromMap(Map m) => ChatRoom( + id: m['id'] as String, + title: m['title'] as String, + kind: m['kind'] as String, + contextKind: m['context_kind'] as String, + membersJson: m['members'] as String, + archived: (m['archived'] as int) == 1, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'title': title, + 'kind': kind, + 'context_kind': contextKind, + 'members': membersJson, + 'archived': archived ? 1 : 0, + 'updated_at': updatedAt, + }; +} diff --git a/lib/modules/storage/src/models/friend_request.dart b/lib/modules/storage/src/models/friend_request.dart new file mode 100644 index 0000000..02a7bae --- /dev/null +++ b/lib/modules/storage/src/models/friend_request.dart @@ -0,0 +1,53 @@ +import 'row.dart'; + +/// 상대가 먼저 보낸 인사(명함 교환 요청). 설정이 "요청 후 상대 수락"이면 여기에 쌓인다. +class FriendRequest implements Row { + const FriendRequest({ + required this.id, + required this.name, + required this.publicKey, + required this.receivedAt, + required this.updatedAt, + this.status = 'pending', + }); + + /// 상대의 전화번호(표준 형태)가 곧 id 다. + @override + final String id; + final String name; + final String publicKey; + final int receivedAt; + final String status; // pending | accepted | declined + @override + final int updatedAt; + + String get phone => id; + + FriendRequest withStatus(String s, int stamp) => FriendRequest( + id: id, + name: name, + publicKey: publicKey, + receivedAt: receivedAt, + status: s, + updatedAt: stamp, + ); + + factory FriendRequest.fromMap(Map m) => FriendRequest( + id: m['id'] as String, + name: m['name'] as String, + publicKey: m['public_key'] as String, + receivedAt: m['received_at'] as int, + status: m['status'] as String, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'name': name, + 'public_key': publicKey, + 'received_at': receivedAt, + 'status': status, + 'updated_at': updatedAt, + }; +} diff --git a/lib/modules/storage/src/models/link.dart b/lib/modules/storage/src/models/link.dart new file mode 100644 index 0000000..94f4c92 --- /dev/null +++ b/lib/modules/storage/src/models/link.dart @@ -0,0 +1,42 @@ +import 'row.dart'; + +/// 나와 한 사람 사이의 연결 고리 (예: 동창, 전 직장 동료, 가족:어머니). +/// 앱이 유추한 것을 사용자가 "맞아요"로 확인하면 [confirmed] 가 된다. +class Link implements Row { + const Link({ + required this.id, + required this.personId, + required this.relation, + required this.detail, + required this.updatedAt, + this.confirmed = false, + }); + + @override + final String id; + final String personId; + final String relation; // 동창 | 동문 | 전 직장 동료 | 같은 모임 | 같은 여행 | 가족:어머니 … + final String detail; // 근거 (예: 가람초 27회) + final bool confirmed; + @override + final int updatedAt; + + factory Link.fromMap(Map m) => Link( + id: m['id'] as String, + personId: m['person_id'] as String, + relation: m['relation'] as String, + detail: m['detail'] as String, + confirmed: (m['confirmed'] as int) == 1, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'person_id': personId, + 'relation': relation, + 'detail': detail, + 'confirmed': confirmed ? 1 : 0, + 'updated_at': updatedAt, + }; +} diff --git a/lib/modules/storage/src/models/message.dart b/lib/modules/storage/src/models/message.dart new file mode 100644 index 0000000..6dfa4bd --- /dev/null +++ b/lib/modules/storage/src/models/message.dart @@ -0,0 +1,55 @@ +import 'row.dart'; + +/// 대화 한 건. [sentAt] 은 보낸 시각(1/100초)이며 순서를 정하는 기준이다. +class Message implements Row { + const Message({ + required this.id, + required this.roomId, + required this.senderId, + required this.body, + required this.sentAt, + required this.updatedAt, + this.archived = false, + }); + + @override + final String id; + final String roomId; + final String senderId; + final String body; + final int sentAt; + final bool archived; // 별도 저장소로 나눈 대화 + @override + final int updatedAt; + + Message withArchived(bool value, int stamp) => Message( + id: id, + roomId: roomId, + senderId: senderId, + body: body, + sentAt: sentAt, + archived: value, + updatedAt: stamp, + ); + + factory Message.fromMap(Map m) => Message( + id: m['id'] as String, + roomId: m['room_id'] as String, + senderId: m['sender_id'] as String, + body: m['body'] as String, + sentAt: m['sent_at'] as int, + archived: (m['archived'] as int) == 1, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'room_id': roomId, + 'sender_id': senderId, + 'body': body, + 'sent_at': sentAt, + 'archived': archived ? 1 : 0, + 'updated_at': updatedAt, + }; +} diff --git a/lib/modules/storage/src/models/my_card.dart b/lib/modules/storage/src/models/my_card.dart new file mode 100644 index 0000000..cca0981 --- /dev/null +++ b/lib/modules/storage/src/models/my_card.dart @@ -0,0 +1,45 @@ +import 'row.dart'; + +/// 내 명함 (개인 · 회사 · 학교 · 그룹 · 가족 등 용도별 여러 장). +class MyCard implements Row { + const MyCard({ + required this.id, + required this.kind, + required this.title, + required this.fieldsJson, + required this.updatedAt, + this.templateId, + this.shareDefault = false, + }); + + @override + final String id; + final String kind; // personal | company | school | group | family + final String title; + final String fieldsJson; // 표시 항목 (JSON 문자열) + final String? templateId; + final bool shareDefault; // 새 친구에게 기본으로 공유 + @override + final int updatedAt; + + factory MyCard.fromMap(Map m) => MyCard( + id: m['id'] as String, + kind: m['kind'] as String, + title: m['title'] as String, + fieldsJson: m['fields'] as String, + templateId: m['template_id'] as String?, + shareDefault: (m['share_default'] as int) == 1, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'kind': kind, + 'title': title, + 'fields': fieldsJson, + 'template_id': templateId, + 'share_default': shareDefault ? 1 : 0, + 'updated_at': updatedAt, + }; +} diff --git a/lib/modules/storage/src/models/my_profile.dart b/lib/modules/storage/src/models/my_profile.dart new file mode 100644 index 0000000..3889183 --- /dev/null +++ b/lib/modules/storage/src/models/my_profile.dart @@ -0,0 +1,7 @@ +/// 나의 기본 정보. 상대에게 보내는 인사(hello)에 들어간다. +class MyProfile { + const MyProfile({required this.phone, required this.name}); + + final String phone; // 표준 형태 전화번호 + final String name; +} diff --git a/lib/modules/storage/src/models/outbox_item.dart b/lib/modules/storage/src/models/outbox_item.dart new file mode 100644 index 0000000..81ab074 --- /dev/null +++ b/lib/modules/storage/src/models/outbox_item.dart @@ -0,0 +1,47 @@ +import 'row.dart'; + +/// 아직 상대에게 전하지 못한 봉투. 연결되면 다시 보낸다. +class OutboxItem implements Row { + const OutboxItem({ + required this.id, + required this.peerPhone, + required this.envelopeJson, + required this.createdAt, + this.attempts = 0, + }); + + @override + final String id; + final String peerPhone; + final String envelopeJson; + final int createdAt; + final int attempts; + + @override + int get updatedAt => createdAt; + + OutboxItem tried() => OutboxItem( + id: id, + peerPhone: peerPhone, + envelopeJson: envelopeJson, + createdAt: createdAt, + attempts: attempts + 1, + ); + + factory OutboxItem.fromMap(Map m) => OutboxItem( + id: m['id'] as String, + peerPhone: m['peer_phone'] as String, + envelopeJson: m['envelope'] as String, + createdAt: m['created_at'] as int, + attempts: m['attempts'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'peer_phone': peerPhone, + 'envelope': envelopeJson, + 'created_at': createdAt, + 'attempts': attempts, + }; +} diff --git a/lib/modules/storage/src/models/person.dart b/lib/modules/storage/src/models/person.dart new file mode 100644 index 0000000..2c977d0 --- /dev/null +++ b/lib/modules/storage/src/models/person.dart @@ -0,0 +1,57 @@ +import 'row.dart'; + +/// 내 인맥의 한 사람(연결 고리). 전화번호(1순위)와 이름(2순위)으로 알아본다. +/// +/// [id] 는 번호나 이름이 바뀌어도 변하지 않는 임의 번호표이고, +/// 대화·명함·공유 기록은 이 id 로 사람을 가리킨다. +class Person implements Row { + const Person({ + required this.id, + required this.name, + required this.phone, + required this.createdAt, + required this.updatedAt, + this.publicKey, + }); + + @override + final String id; + final String name; + + /// 지금 쓰는 전화번호 (표준 형태, 예: +821028475821). + final String phone; + + /// 상대의 공개 키 (base64). 명함을 교환하면 알게 된다. + final String? publicKey; + final int createdAt; + @override + final int updatedAt; + + Person copyWith({String? name, String? phone, String? publicKey, int? updatedAt}) => Person( + id: id, + name: name ?? this.name, + phone: phone ?? this.phone, + publicKey: publicKey ?? this.publicKey, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + + factory Person.fromMap(Map m) => Person( + id: m['id'] as String, + name: m['name'] as String, + phone: m['phone'] as String, + publicKey: m['public_key'] as String?, + createdAt: m['created_at'] as int, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'name': name, + 'phone': phone, + 'public_key': publicKey, + 'created_at': createdAt, + 'updated_at': updatedAt, + }; +} diff --git a/lib/modules/storage/src/models/person_match.dart b/lib/modules/storage/src/models/person_match.dart new file mode 100644 index 0000000..99b00d9 --- /dev/null +++ b/lib/modules/storage/src/models/person_match.dart @@ -0,0 +1,24 @@ +import 'person.dart'; + +/// 새 친구를 등록할 때 이미 아는 사람과 얼마나 겹치는지. +enum MatchKind { + /// 번호와 이름이 모두 같다 (이미 등록된 같은 사람). + same, + + /// 번호는 같은데 이름이 다르다 (이름을 바꿨거나, 번호가 다른 사람에게 넘어간 경우). + nameDiffers, + + /// 이름은 같은데 번호가 처음 보는 번호다 (번호를 바꿨거나, 이름만 같은 다른 사람). + phoneDiffers, + + /// 번호도 이름도 처음이다 (새 사람). + none, +} + +/// 비교 결과. 같은 사람인지는 항상 사용자가 정한다 — 앱은 후보만 보여 준다. +class PersonMatch { + const PersonMatch(this.kind, this.candidates); + + final MatchKind kind; + final List candidates; +} diff --git a/lib/modules/storage/src/models/received_card.dart b/lib/modules/storage/src/models/received_card.dart new file mode 100644 index 0000000..8ee660e --- /dev/null +++ b/lib/modules/storage/src/models/received_card.dart @@ -0,0 +1,41 @@ +import 'row.dart'; + +/// 상대에게서 받은 명함. +class ReceivedCard implements Row { + const ReceivedCard({ + required this.id, + required this.personId, + required this.kind, + required this.fieldsJson, + required this.receivedAt, + required this.updatedAt, + }); + + @override + final String id; + final String personId; // 보낸 사람(Person) id + final String kind; + final String fieldsJson; + final int receivedAt; + @override + final int updatedAt; + + factory ReceivedCard.fromMap(Map m) => ReceivedCard( + id: m['id'] as String, + personId: m['person_id'] as String, + kind: m['kind'] as String, + fieldsJson: m['fields'] as String, + receivedAt: m['received_at'] as int, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'person_id': personId, + 'kind': kind, + 'fields': fieldsJson, + 'received_at': receivedAt, + 'updated_at': updatedAt, + }; +} diff --git a/lib/modules/storage/src/models/row.dart b/lib/modules/storage/src/models/row.dart new file mode 100644 index 0000000..cbff1a7 --- /dev/null +++ b/lib/modules/storage/src/models/row.dart @@ -0,0 +1,6 @@ +/// 표의 한 줄. 모든 줄은 id 와 마지막 변경 시각(1/100초)을 가진다. +abstract class Row { + String get id; + int get updatedAt; + Map toMap(); +} diff --git a/lib/modules/storage/src/models/share_record.dart b/lib/modules/storage/src/models/share_record.dart new file mode 100644 index 0000000..70fe109 --- /dev/null +++ b/lib/modules/storage/src/models/share_record.dart @@ -0,0 +1,90 @@ +import 'row.dart'; + +/// 어떤 명함 장 또는 대화방을 누구와 공유하는지 (공유를 끝내면 [endedAt] 기록). +class ShareRecord implements Row { + const ShareRecord({ + required this.id, + required this.unit, + required this.targetId, + required this.peerId, + required this.startedAt, + required this.updatedAt, + this.endedAt, + this.snapshot, + this.sentVersion, + }); + + @override + final String id; + final String unit; // card | room + final String targetId; // 명함 또는 대화방 ID + final String peerId; // 공유 상대(Person) id + final int startedAt; + final int? endedAt; + + /// 명함 공유 때 상대에게 보낸 내용 (보낸 시점 그대로). + final String? snapshot; + + /// 보낸 시점의 명함 변경 시각. 명함이 이보다 나중에 바뀌었으면 다시 보내야 한다. + final int? sentVersion; + @override + final int updatedAt; + + bool get active => endedAt == null; + + factory ShareRecord.fromMap(Map m) => ShareRecord( + id: m['id'] as String, + unit: m['unit'] as String, + targetId: m['target_id'] as String, + peerId: m['peer_id'] as String, + startedAt: m['started_at'] as int, + endedAt: m['ended_at'] as int?, + snapshot: m['snapshot'] as String?, + sentVersion: m['sent_version'] as int?, + updatedAt: m['updated_at'] as int, + ); + + @override + Map toMap() => { + 'id': id, + 'unit': unit, + 'target_id': targetId, + 'peer_id': peerId, + 'started_at': startedAt, + 'ended_at': endedAt, + 'snapshot': snapshot, + 'sent_version': sentVersion, + 'updated_at': updatedAt, + }; + + /// 공유를 끝낸 복사본. + ShareRecord endedAtStamp(int stamp) => ShareRecord( + id: id, + unit: unit, + targetId: targetId, + peerId: peerId, + startedAt: startedAt, + endedAt: stamp, + snapshot: snapshot, + sentVersion: sentVersion, + updatedAt: stamp, + ); + + /// 보낸 내용과 보낸 시점만 새로 바꾼 복사본 (다시 보내기용). + ShareRecord resent({ + required String snapshot, + required int sentVersion, + required int updatedAt, + }) => + ShareRecord( + id: id, + unit: unit, + targetId: targetId, + peerId: peerId, + startedAt: startedAt, + endedAt: endedAt, + snapshot: snapshot, + sentVersion: sentVersion, + updatedAt: updatedAt, + ); +} diff --git a/lib/modules/storage/src/people_book.dart b/lib/modules/storage/src/people_book.dart new file mode 100644 index 0000000..b502d9a --- /dev/null +++ b/lib/modules/storage/src/people_book.dart @@ -0,0 +1,89 @@ +import 'ids.dart'; +import 'models/person.dart'; +import 'models/person_match.dart'; +import 'repos/interfaces.dart'; + +/// 인맥의 사람들에 대한 규칙. 전화번호(1순위)와 이름(2순위)으로 알아보고, +/// 번호나 이름이 바뀌면 사용자가 고른 사람에게 새 값을 붙이며 이전 값은 남긴다. +/// 저장 방식(메모리/SQLite)과 상관없이 같은 규칙이 돈다. +class PeopleBook { + PeopleBook(this._repo); + + final PeopleRepo _repo; + + Future get(String id) => _repo.get(id); + Future> all() => _repo.all(); + + /// 전화번호(표준 형태)로 찾는다. 지금 번호가 없으면 이전 번호도 본다. + Future byPhone(String phone) async => + await _repo.byPhone(phone) ?? await _repo.byOldPhone(phone); + + /// 이름으로 찾는다 (같은 이름이 여럿일 수 있음). 이전 이름도 본다. + Future> byName(String name) async { + final found = {}; + for (final p in [...await _repo.byName(name), ...await _repo.byOldName(name)]) { + found[p.id] = p; + } + return found.values.toList(); + } + + /// 새 친구를 등록하기 전에 이미 아는 사람과 비교한다. 같은 사람인지는 사용자가 고른다. + Future match(String phone, String name) async { + final byNum = await byPhone(phone); + if (byNum != null) { + final names = {byNum.name, ...await _repo.oldNames(byNum.id)}; + return PersonMatch( + names.contains(name) ? MatchKind.same : MatchKind.nameDiffers, + [byNum], + ); + } + final sameName = await byName(name); + if (sameName.isNotEmpty) return PersonMatch(MatchKind.phoneDiffers, sameName); + return const PersonMatch(MatchKind.none, []); + } + + /// 새 사람으로 등록한다. 같은 번호가 이미 있으면 null (먼저 [match] 로 확인할 것). + Future register(String phone, String name, int stamp, {String? publicKey}) async { + if (await _repo.byPhone(phone) != null) return null; + final p = Person( + id: newId(), + name: name, + phone: phone, + publicKey: publicKey, + createdAt: stamp, + updatedAt: stamp, + ); + await _repo.put(p); + return p; + } + + /// 사용자가 "같은 사람, 번호가 바뀜"을 고르면 새 번호를 붙이고 이전 번호는 남긴다. + /// 새 번호가 다른 사람의 현재 번호면 false. + Future changePhone(String personId, String newPhone, int stamp) async { + final clash = await _repo.byPhone(newPhone); + if (clash != null && clash.id != personId) return false; + final cur = await _repo.get(personId); + if (cur == null) return false; + if (cur.phone == newPhone) return true; + await _repo.addAlias(personId, 'phone', cur.phone, stamp); + await _repo.put(cur.copyWith(phone: newPhone, updatedAt: stamp)); + return true; + } + + /// 사용자가 "같은 사람, 이름이 바뀜"을 고르면 새 이름을 붙이고 이전 이름은 남긴다. + Future changeName(String personId, String newName, int stamp) async { + final cur = await _repo.get(personId); + if (cur == null) return false; + if (cur.name == newName) return true; + await _repo.addAlias(personId, 'name', cur.name, stamp); + await _repo.put(cur.copyWith(name: newName, updatedAt: stamp)); + return true; + } + + /// 상대의 공개 키를 알게 되면 붙인다. + Future setPublicKey(String personId, String publicKey, int stamp) async { + final cur = await _repo.get(personId); + if (cur == null || cur.publicKey == publicKey) return; + await _repo.put(cur.copyWith(publicKey: publicKey, updatedAt: stamp)); + } +} diff --git a/lib/modules/storage/src/phone.dart b/lib/modules/storage/src/phone.dart new file mode 100644 index 0000000..584c6ce --- /dev/null +++ b/lib/modules/storage/src/phone.dart @@ -0,0 +1,25 @@ +/// 전화번호를 하나의 표준 형태(+국가번호 숫자)로 바꾼다. +/// +/// 연결 고리(사람)는 이 값을 식별 키로 쓴다. 같은 사람이 010-1234-5678, +/// 01012345678, +82 10-1234-5678 로 적혀도 같은 키가 된다. +/// 국가번호 없이 0으로 시작하면 [defaultCountry](기본 82, 한국)로 본다. +/// 번호로 볼 수 없으면 null. +String? normalizePhone(String input, {String defaultCountry = '82'}) { + final raw = input.trim(); + final digits = raw.replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.isEmpty) return null; + String full; + if (raw.startsWith('+')) { + full = digits; + } else if (raw.startsWith('00') && digits.length > 4) { + full = digits.substring(2); + } else if (digits.startsWith('0')) { + full = defaultCountry + digits.substring(1); + } else if (digits.startsWith(defaultCountry)) { + full = digits; + } else { + return null; + } + if (full.length < 8 || full.length > 15) return null; + return '+$full'; +} diff --git a/lib/modules/storage/src/repos/interfaces.dart b/lib/modules/storage/src/repos/interfaces.dart new file mode 100644 index 0000000..0acacc1 --- /dev/null +++ b/lib/modules/storage/src/repos/interfaces.dart @@ -0,0 +1,98 @@ +import '../models/chat_room.dart'; +import '../models/friend_request.dart'; +import '../models/link.dart'; +import '../models/message.dart'; +import '../models/my_card.dart'; +import '../models/outbox_item.dart'; +import '../models/person.dart'; +import '../models/received_card.dart'; +import '../models/row.dart'; +import '../models/share_record.dart'; + +/// 표 하나에 대한 기본 읽기·쓰기. 같은 id 는 덮어쓴다. +/// 충돌 판단(먼저 쓴 변경 우선)은 동기화 모듈이 한다. +abstract class Repo { + Future put(T row); + Future get(String id); + Future> all(); + + /// 이 줄을 지운다 (없으면 아무 일도 없다). + Future remove(String id); + + /// 이 줄의 마지막 변경 시각. 없으면 null. + Future updatedAtOf(String id); +} + +abstract class MyCardRepo implements Repo { + Future> byKind(String kind); +} + +abstract class ReceivedCardRepo implements Repo { + /// 받은 시각이 최근인 것부터. + Future> byPerson(String personId); +} + +/// 사람 표. 현재 번호·이름과 이전 번호·이름(별칭)을 따로 찾는다. +abstract class PeopleRepo implements Repo { + Future byPhone(String phone); + Future byOldPhone(String phone); + Future> byName(String name); + Future> byOldName(String name); + Future> oldNames(String personId); + + /// 이전 번호('phone') 또는 이전 이름('name')을 기록한다. + Future addAlias(String personId, String kind, String value, int stamp); +} + +abstract class ChatRoomRepo implements Repo { + /// 최근에 바뀐 순서. + Future> recent({bool includeArchived = false}); +} + +abstract class MessageRepo implements Repo { + /// 한 방의 대화를 시간순으로. [before] 보다 이전 것을 [limit] 건씩 (별도 저장된 것 제외). + Future> page(String roomId, {int? before, int limit = 50}); + + /// 한 방에서 별도 저장되지 않은 대화의 텍스트 크기 (바이트). + Future textBytes(String roomId); + + /// 별도 저장되지 않은 대화의 가장 이른·늦은 시각. 대화가 없으면 null. + Future<(int, int)?> span(String roomId); + + /// [from]~[to](양끝 포함)의 대화를 별도 저장으로 표시하거나(기본) 되돌린다. 바뀐 건수. + Future setArchivedBetween(String roomId, int from, int to, int stamp, + {bool archived = true}); +} + +abstract class ShareRepo implements Repo { + Future> activeFor(String targetId); + + /// 단위('card' 또는 'room')별로 지금 공유 중인 것 전부. + Future> activeOf(String unit); + + Future end(String id, int stamp); +} + +abstract class OutboxRepo { + Future add(OutboxItem item); + + /// 오래된 것부터. + Future> pending(); + Future update(OutboxItem item); + Future remove(String id); +} + +abstract class FriendRequestRepo implements Repo { + Future> pending(); +} + +abstract class LinkRepo implements Repo { + Future> byPerson(String personId); +} + +/// 이름-값 저장 (설정, 이미 처리한 봉투, 충돌 판단 기록에 쓴다). +abstract class KeyValueRepo { + Future get(String key); + Future put(String key, String value, int stamp); + Future> all(); +} diff --git a/lib/modules/storage/src/settings_store.dart b/lib/modules/storage/src/settings_store.dart new file mode 100644 index 0000000..beff95c --- /dev/null +++ b/lib/modules/storage/src/settings_store.dart @@ -0,0 +1,34 @@ +import 'models/app_settings.dart'; +import 'models/my_profile.dart'; +import 'repos/interfaces.dart'; + +/// 설정과 내 기본 정보를 이름-값 저장 위에 얹은 읽기·쓰기. +class SettingsStore { + SettingsStore(this._kv); + + final KeyValueRepo _kv; + + Future load() async => AppSettings.fromMap(await _kv.all()); + + Future save(AppSettings s, int stamp) async { + for (final e in s.toMap().entries) { + await _kv.put(e.key, e.value, stamp); + } + } + + /// 내 정보. 아직 정하지 않았으면 null. + Future loadProfile() async { + final phone = await _kv.get('my_phone'); + final name = await _kv.get('my_name'); + return (phone == null || name == null) ? null : MyProfile(phone: phone, name: name); + } + + Future saveProfile(MyProfile p, int stamp) async { + await _kv.put('my_phone', p.phone, stamp); + await _kv.put('my_name', p.name, stamp); + } + + /// 그 밖의 값(예: 홈 구성 JSON). + Future getText(String key) => _kv.get(key); + Future putText(String key, String value, int stamp) => _kv.put(key, value, stamp); +} diff --git a/lib/modules/storage/src/sqlite/open_sqlite.dart b/lib/modules/storage/src/sqlite/open_sqlite.dart new file mode 100644 index 0000000..85701a9 --- /dev/null +++ b/lib/modules/storage/src/sqlite/open_sqlite.dart @@ -0,0 +1,33 @@ +import 'package:sqflite_common/sqlite_api.dart'; + +import '../storage.dart'; +import 'schema.dart'; +import 'sqlite_cards.dart'; +import 'sqlite_chat.dart'; +import 'sqlite_misc.dart'; +import 'sqlite_people.dart'; + +/// 열려 있는 SQLite 데이터베이스 위에 저장소를 만든다 (표가 없으면 만든다). +/// +/// 데이터베이스를 여는 일(암호 걸기 포함)은 플랫폼마다 달라서 platform_* 모듈이 하고, +/// 테스트는 메모리 데이터베이스를 넘긴다. +Future openSqliteStorage(Database db) async { + for (final sql in createStatements) { + await db.execute(sql); + } + return Storage( + myCards: SqliteMyCardRepo(db), + receivedCards: SqliteReceivedCardRepo(db), + peopleRepo: SqlitePeopleRepo(db), + rooms: SqliteChatRoomRepo(db), + messages: SqliteMessageRepo(db), + shares: SqliteShareRepo(db), + outbox: SqliteOutboxRepo(db), + friendRequests: SqliteFriendRequestRepo(db), + links: SqliteLinkRepo(db), + settingsKv: SqliteKeyValueRepo(db, 'settings'), + seen: SqliteKeyValueRepo(db, 'seen_envelopes'), + meta: SqliteKeyValueRepo(db, 'sync_meta'), + onClose: db.close, + ); +} diff --git a/lib/modules/storage/src/sqlite/schema.dart b/lib/modules/storage/src/sqlite/schema.dart new file mode 100644 index 0000000..a037111 --- /dev/null +++ b/lib/modules/storage/src/sqlite/schema.dart @@ -0,0 +1,129 @@ +/// 표 정의. 시각 열은 모두 1/100초 단위 정수다. +/// 영구 보관이 기본이라 지우지 않고, 별도 저장은 archived 로만 표시한다. +/// 사람(people)은 번호가 바뀌어도 기록이 끊기지 않도록 임의 id 를 따로 두고, +/// 전화번호(1순위)와 이름(2순위)으로 알아본다. 이전 번호·이름은 person_aliases 에 남긴다. +const int schemaVersion = 1; + +/// 몇 번을 실행해도 안전하다 (IF NOT EXISTS). +const List createStatements = [ + ''' +CREATE TABLE IF NOT EXISTS my_cards ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + fields TEXT NOT NULL, + template_id TEXT, + share_default INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +)''', + ''' +CREATE TABLE IF NOT EXISTS received_cards ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + kind TEXT NOT NULL, + fields TEXT NOT NULL, + received_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +)''', + 'CREATE INDEX IF NOT EXISTS idx_received_person ON received_cards (person_id)', + ''' +CREATE TABLE IF NOT EXISTS people ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + phone TEXT NOT NULL, + public_key TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +)''', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_people_phone ON people (phone)', + 'CREATE INDEX IF NOT EXISTS idx_people_name ON people (name)', + ''' +CREATE TABLE IF NOT EXISTS person_aliases ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + kind TEXT NOT NULL, + value TEXT NOT NULL, + changed_at INTEGER NOT NULL +)''', + 'CREATE INDEX IF NOT EXISTS idx_aliases_person ON person_aliases (person_id)', + 'CREATE INDEX IF NOT EXISTS idx_aliases_value ON person_aliases (kind, value)', + ''' +CREATE TABLE IF NOT EXISTS chat_rooms ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + kind TEXT NOT NULL, + context_kind TEXT NOT NULL, + members TEXT NOT NULL, + archived INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +)''', + ''' +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + body TEXT NOT NULL, + sent_at INTEGER NOT NULL, + archived INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +)''', + 'CREATE INDEX IF NOT EXISTS idx_messages_room ON messages (room_id, sent_at)', + ''' +CREATE TABLE IF NOT EXISTS shares ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + target_id TEXT NOT NULL, + peer_id TEXT NOT NULL, + started_at INTEGER NOT NULL, + ended_at INTEGER, + snapshot TEXT, + sent_version INTEGER, + updated_at INTEGER NOT NULL +)''', + 'CREATE INDEX IF NOT EXISTS idx_shares_target ON shares (target_id)', + ''' +CREATE TABLE IF NOT EXISTS outbox ( + id TEXT PRIMARY KEY, + peer_phone TEXT NOT NULL, + envelope TEXT NOT NULL, + created_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0 +)''', + ''' +CREATE TABLE IF NOT EXISTS friend_requests ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + public_key TEXT NOT NULL, + received_at INTEGER NOT NULL, + status TEXT NOT NULL, + updated_at INTEGER NOT NULL +)''', + ''' +CREATE TABLE IF NOT EXISTS links ( + id TEXT PRIMARY KEY, + person_id TEXT NOT NULL, + relation TEXT NOT NULL, + detail TEXT NOT NULL, + confirmed INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +)''', + 'CREATE INDEX IF NOT EXISTS idx_links_person ON links (person_id)', + ''' +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +)''', + ''' +CREATE TABLE IF NOT EXISTS seen_envelopes ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +)''', + ''' +CREATE TABLE IF NOT EXISTS sync_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +)''', +]; diff --git a/lib/modules/storage/src/sqlite/sqlite_cards.dart b/lib/modules/storage/src/sqlite/sqlite_cards.dart new file mode 100644 index 0000000..11a2959 --- /dev/null +++ b/lib/modules/storage/src/sqlite/sqlite_cards.dart @@ -0,0 +1,29 @@ +import 'package:sqflite_common/sqlite_api.dart'; + +import '../models/link.dart'; +import '../models/my_card.dart'; +import '../models/received_card.dart'; +import '../repos/interfaces.dart'; +import 'sqlite_repo.dart'; + +class SqliteMyCardRepo extends SqliteRepo implements MyCardRepo { + SqliteMyCardRepo(Database db) : super(db, 'my_cards', MyCard.fromMap); + + @override + Future> byKind(String kind) => where('kind = ?', [kind]); +} + +class SqliteReceivedCardRepo extends SqliteRepo implements ReceivedCardRepo { + SqliteReceivedCardRepo(Database db) : super(db, 'received_cards', ReceivedCard.fromMap); + + @override + Future> byPerson(String personId) => + where('person_id = ?', [personId], orderBy: 'received_at DESC'); +} + +class SqliteLinkRepo extends SqliteRepo implements LinkRepo { + SqliteLinkRepo(Database db) : super(db, 'links', Link.fromMap); + + @override + Future> byPerson(String personId) => where('person_id = ?', [personId]); +} diff --git a/lib/modules/storage/src/sqlite/sqlite_chat.dart b/lib/modules/storage/src/sqlite/sqlite_chat.dart new file mode 100644 index 0000000..1197c74 --- /dev/null +++ b/lib/modules/storage/src/sqlite/sqlite_chat.dart @@ -0,0 +1,98 @@ +import 'package:sqflite_common/sqlite_api.dart'; + +import '../models/chat_room.dart'; +import '../models/friend_request.dart'; +import '../models/message.dart'; +import '../models/share_record.dart'; +import '../repos/interfaces.dart'; +import 'sqlite_repo.dart'; + +class SqliteChatRoomRepo extends SqliteRepo implements ChatRoomRepo { + SqliteChatRoomRepo(Database db) : super(db, 'chat_rooms', ChatRoom.fromMap); + + @override + Future> recent({bool includeArchived = false}) => where( + includeArchived ? '1 = 1' : 'archived = 0', + const [], + orderBy: 'updated_at DESC', + ); +} + +class SqliteMessageRepo extends SqliteRepo implements MessageRepo { + SqliteMessageRepo(Database db) : super(db, 'messages', Message.fromMap); + + @override + Future> page(String roomId, {int? before, int limit = 50}) async { + final rows = await where( + before == null + ? 'room_id = ? AND archived = 0' + : 'room_id = ? AND archived = 0 AND sent_at < ?', + before == null ? [roomId] : [roomId, before], + orderBy: 'sent_at DESC', + limit: limit, + ); + return rows.reversed.toList(); + } + + @override + Future textBytes(String roomId) async { + final r = await db.rawQuery( + 'SELECT COALESCE(SUM(LENGTH(CAST(body AS BLOB))), 0) AS n ' + 'FROM messages WHERE room_id = ? AND archived = 0', + [roomId], + ); + return (r.first['n'] as int?) ?? 0; + } + + @override + Future<(int, int)?> span(String roomId) async { + final r = await db.rawQuery( + 'SELECT MIN(sent_at) AS a, MAX(sent_at) AS b FROM messages ' + 'WHERE room_id = ? AND archived = 0', + [roomId], + ); + final a = r.first['a'] as int?; + final b = r.first['b'] as int?; + return (a == null || b == null) ? null : (a, b); + } + + @override + Future setArchivedBetween(String roomId, int from, int to, int stamp, + {bool archived = true}) => + db.update( + 'messages', + {'archived': archived ? 1 : 0, 'updated_at': stamp}, + where: 'room_id = ? AND archived = ? AND sent_at >= ? AND sent_at <= ?', + whereArgs: [roomId, archived ? 0 : 1, from, to], + ); +} + +class SqliteShareRepo extends SqliteRepo implements ShareRepo { + SqliteShareRepo(Database db) : super(db, 'shares', ShareRecord.fromMap); + + @override + Future> activeFor(String targetId) => + where('target_id = ? AND ended_at IS NULL', [targetId]); + + @override + Future> activeOf(String unit) => + where('unit = ? AND ended_at IS NULL', [unit]); + + @override + Future end(String id, int stamp) async { + await db.update( + 'shares', + {'ended_at': stamp, 'updated_at': stamp}, + where: 'id = ?', + whereArgs: [id], + ); + } +} + +class SqliteFriendRequestRepo extends SqliteRepo implements FriendRequestRepo { + SqliteFriendRequestRepo(Database db) : super(db, 'friend_requests', FriendRequest.fromMap); + + @override + Future> pending() => + where("status = 'pending'", const [], orderBy: 'received_at ASC'); +} diff --git a/lib/modules/storage/src/sqlite/sqlite_misc.dart b/lib/modules/storage/src/sqlite/sqlite_misc.dart new file mode 100644 index 0000000..5660111 --- /dev/null +++ b/lib/modules/storage/src/sqlite/sqlite_misc.dart @@ -0,0 +1,52 @@ +import 'package:sqflite_common/sqlite_api.dart'; + +import '../models/outbox_item.dart'; +import '../repos/interfaces.dart'; + +class SqliteOutboxRepo implements OutboxRepo { + SqliteOutboxRepo(this.db); + + final Database db; + + @override + Future add(OutboxItem item) => + db.insert('outbox', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + + @override + Future> pending() async => + (await db.query('outbox', orderBy: 'created_at ASC')).map(OutboxItem.fromMap).toList(); + + @override + Future update(OutboxItem item) => add(item); + + @override + Future remove(String id) async { + await db.delete('outbox', where: 'id = ?', whereArgs: [id]); + } +} + +/// 이름-값 표 (settings, seen_envelopes, sync_meta 가 같은 모양이다). +class SqliteKeyValueRepo implements KeyValueRepo { + SqliteKeyValueRepo(this.db, this.table); + + final Database db; + final String table; + + @override + Future get(String key) async { + final r = await db.query(table, where: 'key = ?', whereArgs: [key], limit: 1); + return r.isEmpty ? null : r.first['value'] as String; + } + + @override + Future put(String key, String value, int stamp) => db.insert( + table, + {'key': key, 'value': value, 'updated_at': stamp}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + @override + Future> all() async => { + for (final r in await db.query(table)) r['key'] as String: r['value'] as String, + }; +} diff --git a/lib/modules/storage/src/sqlite/sqlite_people.dart b/lib/modules/storage/src/sqlite/sqlite_people.dart new file mode 100644 index 0000000..b38eea2 --- /dev/null +++ b/lib/modules/storage/src/sqlite/sqlite_people.dart @@ -0,0 +1,59 @@ +import 'package:sqflite_common/sqlite_api.dart'; + +import '../ids.dart'; +import '../models/person.dart'; +import '../repos/interfaces.dart'; +import 'sqlite_repo.dart'; + +class SqlitePeopleRepo extends SqliteRepo implements PeopleRepo { + SqlitePeopleRepo(Database db) : super(db, 'people', Person.fromMap); + + @override + Future byPhone(String phone) async { + final r = await where('phone = ?', [phone], limit: 1); + return r.isEmpty ? null : r.first; + } + + @override + Future byOldPhone(String phone) async { + final a = await db.query('person_aliases', + columns: ['person_id'], + where: "kind = 'phone' AND value = ?", + whereArgs: [phone], + limit: 1); + return a.isEmpty ? null : get(a.first['person_id'] as String); + } + + @override + Future> byName(String name) => where('name = ?', [name]); + + @override + Future> byOldName(String name) async { + final rows = await db.rawQuery( + 'SELECT DISTINCT p.* FROM people p ' + "JOIN person_aliases a ON a.person_id = p.id AND a.kind = 'name' " + 'WHERE a.value = ?', + [name], + ); + return rows.map(Person.fromMap).toList(); + } + + @override + Future> oldNames(String personId) async { + final rows = await db.query('person_aliases', + columns: ['value'], + where: "person_id = ? AND kind = 'name'", + whereArgs: [personId]); + return {for (final r in rows) r['value'] as String}; + } + + @override + Future addAlias(String personId, String kind, String value, int stamp) => + db.insert('person_aliases', { + 'id': newId(), + 'person_id': personId, + 'kind': kind, + 'value': value, + 'changed_at': stamp, + }); +} diff --git a/lib/modules/storage/src/sqlite/sqlite_repo.dart b/lib/modules/storage/src/sqlite/sqlite_repo.dart new file mode 100644 index 0000000..b918af1 --- /dev/null +++ b/lib/modules/storage/src/sqlite/sqlite_repo.dart @@ -0,0 +1,43 @@ +import 'package:sqflite_common/sqlite_api.dart'; + +import '../models/row.dart'; +import '../repos/interfaces.dart'; + +/// SQLite 표 하나에 대한 기본 읽기·쓰기. +class SqliteRepo implements Repo { + SqliteRepo(this.db, this.table, this.fromMap); + + final Database db; + final String table; + final T Function(Map) fromMap; + + @override + Future put(T row) => + db.insert(table, row.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + + @override + Future get(String id) async { + final r = await db.query(table, where: 'id = ?', whereArgs: [id], limit: 1); + return r.isEmpty ? null : fromMap(r.first); + } + + @override + Future remove(String id) => db.delete(table, where: 'id = ?', whereArgs: [id]); + + @override + Future> all() async => (await db.query(table)).map(fromMap).toList(); + + @override + Future updatedAtOf(String id) async { + final r = await db.query(table, + columns: ['updated_at'], where: 'id = ?', whereArgs: [id], limit: 1); + return r.isEmpty ? null : r.first['updated_at'] as int; + } + + /// 조건에 맞는 줄들. + Future> where(String where, List args, {String? orderBy, int? limit}) async { + final r = await db.query(table, + where: where, whereArgs: args, orderBy: orderBy, limit: limit); + return r.map(fromMap).toList(); + } +} diff --git a/lib/modules/storage/src/storage.dart b/lib/modules/storage/src/storage.dart new file mode 100644 index 0000000..9664dfc --- /dev/null +++ b/lib/modules/storage/src/storage.dart @@ -0,0 +1,68 @@ +import 'memory/memory_cards.dart'; +import 'memory/memory_chat.dart'; +import 'memory/memory_misc.dart'; +import 'memory/memory_people.dart'; +import 'people_book.dart'; +import 'repos/interfaces.dart'; +import 'settings_store.dart'; + +/// 저장소 묶음. 표별 저장 도구를 필드로 쓴다. +/// +/// 저장 방식은 갈아 끼울 수 있다. 테스트와 화면 미리보기는 [Storage.memory], +/// 실제 앱은 storage_sqlite.dart 의 openSqliteStorage 로 만든다. +class Storage { + Storage({ + required this.myCards, + required this.receivedCards, + required PeopleRepo peopleRepo, + required this.rooms, + required this.messages, + required this.shares, + required this.outbox, + required this.friendRequests, + required this.links, + required KeyValueRepo settingsKv, + required this.seen, + required this.meta, + Future Function()? onClose, + }) : people = PeopleBook(peopleRepo), + settings = SettingsStore(settingsKv), + _onClose = onClose; + + /// 모두 메모리에만 두는 저장소 (테스트·미리보기용). + factory Storage.memory() => Storage( + myCards: MemoryMyCardRepo(), + receivedCards: MemoryReceivedCardRepo(), + peopleRepo: MemoryPeopleRepo(), + rooms: MemoryChatRoomRepo(), + messages: MemoryMessageRepo(), + shares: MemoryShareRepo(), + outbox: MemoryOutboxRepo(), + friendRequests: MemoryFriendRequestRepo(), + links: MemoryLinkRepo(), + settingsKv: MemoryKeyValueRepo(), + seen: MemoryKeyValueRepo(), + meta: MemoryKeyValueRepo(), + ); + + final MyCardRepo myCards; + final ReceivedCardRepo receivedCards; + final PeopleBook people; + final ChatRoomRepo rooms; + final MessageRepo messages; + final ShareRepo shares; + final OutboxRepo outbox; + final FriendRequestRepo friendRequests; + final LinkRepo links; + final SettingsStore settings; + + /// 이미 처리한 봉투 ID (같은 봉투를 두 번 반영하지 않기 위해). + final KeyValueRepo seen; + + /// 충돌 판단에 쓰는 기록 (줄마다 누가 언제 썼는지). + final KeyValueRepo meta; + + final Future Function()? _onClose; + + Future close() async => _onClose?.call(); +} diff --git a/lib/modules/storage/storage.dart b/lib/modules/storage/storage.dart new file mode 100644 index 0000000..50dffe1 --- /dev/null +++ b/lib/modules/storage/storage.dart @@ -0,0 +1,27 @@ +/// 저장소: 표 약속(인터페이스), 메모리 구현, 사람·설정 규칙, 별도 저장 판단 +/// +/// SQLite 구현은 storage_sqlite.dart 에 따로 있다 (이 파일은 SQLite 를 모른다). +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/archive_advice.dart'; +export 'src/archive_service.dart'; +export 'src/clock.dart'; +export 'src/ids.dart'; +export 'src/models/app_settings.dart'; +export 'src/models/chat_room.dart'; +export 'src/models/friend_request.dart'; +export 'src/models/link.dart'; +export 'src/models/message.dart'; +export 'src/models/my_card.dart'; +export 'src/models/my_profile.dart'; +export 'src/models/outbox_item.dart'; +export 'src/models/person.dart'; +export 'src/models/person_match.dart'; +export 'src/models/received_card.dart'; +export 'src/models/share_record.dart'; +export 'src/people_book.dart'; +export 'src/phone.dart'; +export 'src/repos/interfaces.dart'; +export 'src/settings_store.dart'; +export 'src/storage.dart'; diff --git a/lib/modules/storage/storage_sqlite.dart b/lib/modules/storage/storage_sqlite.dart new file mode 100644 index 0000000..e8e3171 --- /dev/null +++ b/lib/modules/storage/storage_sqlite.dart @@ -0,0 +1,7 @@ +/// 저장소(SQLite 구현): 열려 있는 SQLite 데이터베이스 위에 저장소를 만든다. +/// +/// 이 파일은 storage 모듈의 SQLite 입구다. 데이터베이스를 여는 쪽(platform_*)과 +/// SQLite 로 테스트하는 쪽에서만 import 한다. +library; + +export 'src/sqlite/open_sqlite.dart'; diff --git a/lib/modules/sync/src/clock_sync.dart b/lib/modules/sync/src/clock_sync.dart new file mode 100644 index 0000000..63546fe --- /dev/null +++ b/lib/modules/sync/src/clock_sync.dart @@ -0,0 +1,40 @@ +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +/// 기기 시계 오차를 바로잡는다. 모든 변경 시각의 바탕이다. +/// +/// 1순위는 외부 보관함의 서버 시각, 서버에 못 닿으면 같은 네트워크 상대가 보낸 시각을 쓴다. +class ClockSync { + ClockSync(this._clock); + + final Clock _clock; + + /// 마지막으로 맞춘 기준 ('server' | 'peer' | null). + String? source; + + int _raw() => DateTime.now().millisecondsSinceEpoch ~/ 10; + + /// 서버 시각으로 맞춘다. 맞췄으면 true. + Future syncWith(TimeSource server) async { + final ref = await server.referenceStamp(); + if (ref == null) return false; + _clock.offset = ref - _raw(); + source = 'server'; + return true; + } + + /// 같은 네트워크 상대가 보낸 시각으로 맞춘다. 서버 시각으로 이미 맞췄으면 건드리지 않는다. + /// + /// 1초 미만의 차이는 전송 지연일 수 있어 맞추지 않는다 (맞추면 오히려 시각 순서가 흔들린다). + bool syncWithPeer(int peerStamp) { + if (source == 'server') return false; + final diff = peerStamp - _raw(); + source = 'peer'; + if (diff.abs() < minPeerDiff) return false; + _clock.offset = diff; + return true; + } + + /// 상대 시각과 이만큼(1/100초) 넘게 다를 때만 맞춘다. + static const int minPeerDiff = 100; +} diff --git a/lib/modules/sync/src/conflict.dart b/lib/modules/sync/src/conflict.dart new file mode 100644 index 0000000..d322106 --- /dev/null +++ b/lib/modules/sync/src/conflict.dart @@ -0,0 +1,26 @@ +import 'package:friendly_map/modules/storage/storage.dart'; + +/// 같은 줄에 두 변경이 부딪칠 때 누구를 따를지 정한다. +/// +/// - 같은 사람이 다시 쓴 변경은 더 늦은 것이 이긴다 (고친 명함이 이전 것을 대체). +/// - 서로 다른 사람의 변경이 부딪치면 **먼저 쓴 변경**이 이긴다. +/// 시각은 1/100초 단위이고, 기록은 [KeyValueRepo] 한 곳에 '쓴 사람|시각' 으로 남긴다. +class ConflictRule { + ConflictRule(this._meta); + + final KeyValueRepo _meta; + + /// [key] 줄에 [author] 가 [stamp] 에 쓴 변경을 반영해도 되는지. 반영해도 되면 기록까지 남긴다. + Future accept(String key, String author, int stamp) async { + final rec = await _meta.get(key); + if (rec != null) { + final cut = rec.lastIndexOf('|'); + final oldAuthor = rec.substring(0, cut); + final oldStamp = int.parse(rec.substring(cut + 1)); + final win = oldAuthor == author ? stamp >= oldStamp : stamp < oldStamp; + if (!win) return false; + } + await _meta.put(key, '$author|$stamp', stamp); + return true; + } +} diff --git a/lib/modules/sync/src/inbound.dart b/lib/modules/sync/src/inbound.dart new file mode 100644 index 0000000..dc83512 --- /dev/null +++ b/lib/modules/sync/src/inbound.dart @@ -0,0 +1,234 @@ +import 'dart:convert'; + +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import 'sync_context.dart'; +import 'outbound.dart' show recoveryUnit, heldRecoveryKey, heldRecoveryIndexKey, locationUnit, friendLocationKey, friendLocationIndexKey; +import 'sync_event.dart'; + +/// 받기: 봉투를 풀어서 저장소에 반영한다. +/// +/// 아직 처리할 수 없는 봉투(상대 키를 모름, 대화방·명함이 아직 안 옴)는 보류해 두었다가 +/// [retryHeld] 로 다시 처리한다. 같은 봉투를 두 번 반영하지 않는다. +class Inbound { + Inbound(this._c); + + static const _heldPrefix = 'held:'; + final SyncContext _c; + bool _retrying = false; + + Future handle(Envelope e) async { + if (await _c.storage.seen.get(e.id) == '1') return Outcome.ignored; + final me = await _c.me(); + if (me == null || e.to != me.phone) return Outcome.ignored; + final outcome = await _route(e); + if (outcome == Outcome.held) { + await _c.storage.seen.put('$_heldPrefix${e.id}', e.encode(), e.stamp); + } else { + await _c.storage.seen.put(e.id, '1', e.stamp); + await _c.storage.seen.put('$_heldPrefix${e.id}', '', e.stamp); + if (outcome == Outcome.applied) await retryHeld(); + } + return outcome; + } + + /// 보류된 봉투를 다시 처리한다. 반영된 개수를 돌려준다. + Future retryHeld() async { + if (_retrying) return 0; + _retrying = true; + var applied = 0; + try { + var progress = true; + while (progress) { + progress = false; + final all = await _c.storage.seen.all(); + for (final en in all.entries) { + if (!en.key.startsWith(_heldPrefix) || en.value.isEmpty) continue; + final e = Envelope.decode(en.value); + final out = await _route(e); + if (out != Outcome.held) { + await _c.storage.seen.put(e.id, '1', e.stamp); + await _c.storage.seen.put(en.key, '', e.stamp); + if (out == Outcome.applied) { + applied++; + progress = true; + } + } + } + } + } finally { + _retrying = false; + } + return applied; + } + + Future _route(Envelope e) async { + try { + switch (e.kind) { + case 'hello': + return _hello(e); + case 'card.share': + return await _sealed(e, _card); + case 'chat.room': + return await _sealed(e, _room); + case 'chat.message': + return await _sealed(e, _message); + case 'recovery.share': + return await _sealed(e, _recovery); + case 'location.update': + return await _sealed(e, _location); + } + } catch (_) { + return Outcome.ignored; // 깨진 봉투는 버린다 + } + return Outcome.ignored; + } + + Outcome _emitHello(Envelope e, Map j) { + _c.events.add(SyncEvent( + SyncEventType.hello, + phone: e.from, + name: j['name'] as String?, + publicKey: j['key'] as String?, + isReply: j['reply'] == true, + stamp: e.stamp, + )); + return Outcome.applied; + } + + Outcome _hello(Envelope e) { + final j = (jsonDecode(e.body) as Map).cast(); + if (j['key'] is! String || j['name'] is! String) return Outcome.ignored; + return _emitHello(e, j); + } + + Future _sealed( + Envelope e, + Future Function(Envelope, Person, Map) apply, + ) async { + final person = await _c.storage.people.byPhone(e.from); + final key = person?.publicKey; + if (person == null || key == null) return Outcome.held; + final plain = await _c.cipher.open(sealed: e.body, peerPublicKey: key, context: e.unitId); + if (plain == null) return Outcome.ignored; // 키가 다르거나 중간에 바뀜 + return apply(e, person, (jsonDecode(plain) as Map).cast()); + } + + Future _card(Envelope e, Person from, Map j) async { + if (!await _c.conflict.accept('rc:${e.unitId}', from.id, e.stamp)) return Outcome.ignored; + final old = await _c.storage.receivedCards.get(e.unitId); + await _c.storage.receivedCards.put(ReceivedCard( + id: e.unitId, + personId: from.id, + kind: j['kind'] as String, + fieldsJson: j['fields'] as String, + receivedAt: old?.receivedAt ?? _c.clock.now(), + updatedAt: e.stamp, + )); + _c.events.add(SyncEvent(SyncEventType.card, personId: from.id, refId: e.unitId, stamp: e.stamp)); + return Outcome.applied; + } + + Future _room(Envelope e, Person from, Map j) async { + final context = j['context'] as String; + if (context != CardKinds.personal && !await _hasCard(from.id, context)) { + return Outcome.held; // 그 맥락의 명함을 받으면 열린다 + } + if (!await _c.conflict.accept('room:${e.unitId}', from.id, e.stamp)) return Outcome.ignored; + final me = await _c.me(); + final ids = {from.id}; + for (final m in (j['members'] as List).cast()) { + final phone = m['phone'] as String; + if (phone == me?.phone) continue; + final p = await _c.storage.people.byPhone(phone); + if (p != null) ids.add(p.id); + } + await _c.storage.rooms.put(ChatRoom( + id: e.unitId, + title: j['title'] as String, + kind: j['kind'] as String, + contextKind: context, + membersJson: jsonEncode(ids.toList()), + updatedAt: e.stamp, + )); + // 받은 방도 같은 상대들과 공유 중이어야 내가 쓴 대화가 그들에게 간다. + for (final id in ids) { + final shareId = 'room:${e.unitId}:$id'; + final old = await _c.storage.shares.get(shareId); + if (old != null && old.active) continue; + await _c.storage.shares.put(ShareRecord( + id: shareId, + unit: 'room', + targetId: e.unitId, + peerId: id, + startedAt: _c.clock.now(), + updatedAt: _c.clock.now(), + )); + } + _c.events.add(SyncEvent(SyncEventType.room, personId: from.id, refId: e.unitId, stamp: e.stamp)); + return Outcome.applied; + } + + Future _message(Envelope e, Person from, Map j) async { + if (await _c.storage.rooms.get(e.unitId) == null) return Outcome.held; + final id = j['id'] as String; + if (!await _c.conflict.accept('msg:$id', from.id, e.stamp)) return Outcome.ignored; + await _c.storage.messages.put(Message( + id: id, + roomId: e.unitId, + senderId: from.id, + body: j['body'] as String, + sentAt: j['sentAt'] as int, + updatedAt: e.stamp, + )); + _c.events.add(SyncEvent(SyncEventType.message, personId: from.id, refId: id, stamp: e.stamp)); + return Outcome.applied; + } + + /// 친구가 맡긴 복구 조각을 보관한다 (맡긴 사람 번호별로 하나, 새로 오면 바뀐다). + Future _recovery(Envelope e, Person from, Map j) async { + if (e.unitId != recoveryUnit(from.phone)) return Outcome.ignored; // 남의 이름으로 맡기는 것을 막는다 + final code = j['code']; + if (code is! String || code.isEmpty) return Outcome.ignored; + if (!await _c.conflict.accept('rs:${e.unitId}', from.id, e.stamp)) return Outcome.ignored; + await _c.storage.settings.putText(heldRecoveryKey(from.phone), code, e.stamp); + final raw = await _c.storage.settings.getText(heldRecoveryIndexKey); + final owners = raw == null || raw.isEmpty ? [] : (jsonDecode(raw) as List).cast(); + if (!owners.contains(from.phone)) { + owners.add(from.phone); + await _c.storage.settings.putText(heldRecoveryIndexKey, jsonEncode(owners), e.stamp); + } + _c.events.add(SyncEvent(SyncEventType.recovery, personId: from.id, stamp: e.stamp)); + return Outcome.applied; + } + + /// 친구의 현재 위치. 지난 위치는 쓸모가 없으니 더 새로운 것만 남긴다 (충돌 규칙을 쓰지 않는다). + Future _location(Envelope e, Person from, Map j) async { + if (e.unitId != locationUnit(from.phone)) return Outcome.ignored; + final lat = j['lat'], lng = j['lng'], at = j['at']; + if (lat is! num || lng is! num || at is! int) return Outcome.ignored; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return Outcome.ignored; + final old = await _c.storage.settings.getText(friendLocationKey(from.phone)); + if (old != null && old.isNotEmpty) { + final oldAt = (jsonDecode(old) as Map)['at']; + if (oldAt is int && oldAt >= at) return Outcome.ignored; + } + await _c.storage.settings.putText(friendLocationKey(from.phone), jsonEncode({'lat': lat, 'lng': lng, 'at': at}), e.stamp); + final raw = await _c.storage.settings.getText(friendLocationIndexKey); + final owners = raw == null || raw.isEmpty ? [] : (jsonDecode(raw) as List).cast(); + if (!owners.contains(from.phone)) { + owners.add(from.phone); + await _c.storage.settings.putText(friendLocationIndexKey, jsonEncode(owners), e.stamp); + } + _c.events.add(SyncEvent(SyncEventType.location, personId: from.id, stamp: e.stamp)); + return Outcome.applied; + } + + Future _hasCard(String personId, String kind) async { + for (final c in await _c.storage.receivedCards.byPerson(personId)) { + if (c.kind == kind) return true; + } + return false; + } +} diff --git a/lib/modules/sync/src/outbound.dart b/lib/modules/sync/src/outbound.dart new file mode 100644 index 0000000..610520f --- /dev/null +++ b/lib/modules/sync/src/outbound.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; + +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import 'sync_context.dart'; +import 'sync_event.dart'; + +/// 복구 조각 봉투의 단위 이름 (맡기는 사람 번호별로 하나). +String recoveryUnit(String ownerPhone) => 'recovery:$ownerPhone'; + +/// 친구가 맡아 둔 복구 조각을 적어 두는 설정 이름: 맡긴 사람 번호별. +String heldRecoveryKey(String ownerPhone) => 'recovery_held:$ownerPhone'; + +/// 친구 위치 봉투의 단위 이름 (보내는 사람 번호별로 하나). +String locationUnit(String ownerPhone) => 'location:$ownerPhone'; + +/// 받은 친구 위치를 적어 두는 설정 이름: 보낸 사람 번호별. +String friendLocationKey(String ownerPhone) => 'loc:$ownerPhone'; + +/// 위치를 받은 친구 번호 목록(JSON)을 적어 두는 설정 이름. +const String friendLocationIndexKey = 'loc_index'; + +/// 맡아 둔 조각의 주인 번호 목록(JSON)을 적어 두는 설정 이름. +const String heldRecoveryIndexKey = 'recovery_held_index'; + +/// 내보내기: 봉투를 만들어 보내고, 못 보낸 것은 보낼함에 둔다. +class Outbound { + Outbound(this._c); + + final SyncContext _c; + + /// 인사. 내 이름과 공개 키를 상대에게 알린다. [reply] 는 상대 인사에 대한 답일 때. + Future hello(String peerPhone, {bool reply = false}) async { + final me = await _c.me(); + if (me == null) return SendResult.noTarget; + final body = jsonEncode({ + 'name': me.name, + 'key': await _c.identity.myPublicKey(), + 'reply': reply, + }); + return _dispatch(_envelope(me.phone, peerPhone, 'hello', '', body)); + } + + /// 공유 중인 명함을 상대에게 보낸다 (처음 공유 또는 "다시 보내기"). + Future card(ShareRecord share) async { + if (share.unit != 'card') return SendResult.noTarget; + final card = await _c.storage.myCards.get(share.targetId); + final peer = await _c.storage.people.get(share.peerId); + if (card == null || peer == null) return SendResult.noTarget; + final plain = jsonEncode({ + 'kind': card.kind, + 'title': card.title, + 'fields': share.snapshot ?? card.fieldsJson, + }); + return _sealAndSend(peer, 'card.share', card.id, plain); + } + + /// 공유 중인 대화방의 정보(제목·맥락·참여자)를 상대에게 보낸다. + Future room(ShareRecord share) async { + if (share.unit != 'room') return SendResult.noTarget; + final room = await _c.storage.rooms.get(share.targetId); + final peer = await _c.storage.people.get(share.peerId); + final me = await _c.me(); + if (room == null || peer == null || me == null) return SendResult.noTarget; + final members = >[ + {'phone': me.phone, 'name': me.name}, + ]; + for (final id in (jsonDecode(room.membersJson) as List).cast()) { + final p = await _c.storage.people.get(id); + if (p != null) members.add({'phone': p.phone, 'name': p.name}); + } + final plain = jsonEncode({ + 'title': room.title, + 'kind': room.kind, + 'context': room.contextKind, + 'members': members, + }); + return _sealAndSend(peer, 'chat.room', room.id, plain); + } + + /// 복구 조각 한 장을 친구에게 맡긴다. 둘만 아는 키로 잠가 보낸다. + Future recovery(Person peer, String code) async { + final me = await _c.me(); + if (me == null) return SendResult.noTarget; + return _sealAndSend(peer, 'recovery.share', recoveryUnit(me.phone), jsonEncode({'code': code})); + } + + /// 내 현재 위치를 친구에게 보낸다 (둘만 아는 키로 잠금). 보낸 시각 [at] 은 1/100초. + Future location(Person peer, double lat, double lng, int at) async { + final me = await _c.me(); + if (me == null) return SendResult.noTarget; + return _sealAndSend(peer, 'location.update', locationUnit(me.phone), jsonEncode({'lat': lat, 'lng': lng, 'at': at})); + } + + /// 대화 한 건을 그 방을 공유 중인 모든 상대에게 보낸다. 보낸 상대별 결과를 돌려준다. + Future> message(Message m) async { + final results = []; + for (final s in await _c.storage.shares.activeFor(m.roomId)) { + if (s.unit != 'room') continue; + final peer = await _c.storage.people.get(s.peerId); + if (peer == null) continue; + final plain = jsonEncode({'id': m.id, 'body': m.body, 'sentAt': m.sentAt}); + results.add(await _sealAndSend(peer, 'chat.message', m.roomId, plain)); + } + return results; + } + + /// 보낼함에 쌓인 것을 다시 보내 본다. 보낸 개수를 돌려준다. + Future flush() async { + var sent = 0; + for (final item in await _c.storage.outbox.pending()) { + var ok = false; + try { + ok = await _c.transport.send(Envelope.decode(item.envelopeJson)); + } catch (_) {} + if (ok) { + await _c.storage.outbox.remove(item.id); + sent++; + } else { + await _c.storage.outbox.update(item.tried()); + } + } + return sent; + } + + Future _sealAndSend(Person peer, String kind, String unitId, String plain) async { + final key = peer.publicKey; + final me = await _c.me(); + if (key == null || me == null) return SendResult.noKey; + final sealed = await _c.cipher.seal(plain: plain, peerPublicKey: key, context: unitId); + return _dispatch(_envelope(me.phone, peer.phone, kind, unitId, sealed)); + } + + Envelope _envelope(String from, String to, String kind, String unit, String body) => + Envelope(id: newId(), from: from, to: to, kind: kind, unitId: unit, body: body, stamp: _c.clock.now()); + + Future _dispatch(Envelope e) async { + var ok = false; + try { + ok = await _c.transport.send(e); + } catch (_) {} + if (ok) return SendResult.sent; + await _c.storage.outbox.add( + OutboxItem(id: e.id, peerPhone: e.to, envelopeJson: e.encode(), createdAt: _c.clock.now()), + ); + return SendResult.queued; + } +} diff --git a/lib/modules/sync/src/sync_context.dart b/lib/modules/sync/src/sync_context.dart new file mode 100644 index 0000000..f354268 --- /dev/null +++ b/lib/modules/sync/src/sync_context.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import 'conflict.dart'; +import 'sync_event.dart'; + +/// 동기화 부품들이 함께 쓰는 재료 묶음. +class SyncContext { + SyncContext({ + required this.storage, + required this.transport, + required this.cipher, + required this.identity, + required this.clock, + }) : conflict = ConflictRule(storage.meta); + + final Storage storage; + final Transport transport; + final PayloadCipher cipher; + final IdentityProvider identity; + final Clock clock; + final ConflictRule conflict; + final StreamController events = StreamController.broadcast(sync: true); + + Future me() => storage.settings.loadProfile(); +} diff --git a/lib/modules/sync/src/sync_engine.dart b/lib/modules/sync/src/sync_engine.dart new file mode 100644 index 0000000..e252c49 --- /dev/null +++ b/lib/modules/sync/src/sync_engine.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import 'clock_sync.dart'; +import 'inbound.dart'; +import 'outbound.dart'; +import 'sync_context.dart'; +import 'sync_event.dart'; + +/// 동기화의 입구. 내보내기·받기·시계 보정을 한곳에서 쓰게 묶는다. +/// +/// 저장소·전송·암호는 모두 core 의 약속으로만 받아서, 어느 것이든 갈아 끼울 수 있다. +class SyncEngine { + SyncEngine({ + required Storage storage, + required Transport transport, + required PayloadCipher cipher, + required IdentityProvider identity, + required Clock clock, + TimeSource? timeSource, + }) : _c = SyncContext( + storage: storage, + transport: transport, + cipher: cipher, + identity: identity, + clock: clock, + ), + _time = timeSource, + clockSync = ClockSync(clock) { + _out = Outbound(_c); + _in = Inbound(_c); + } + + final SyncContext _c; + final TimeSource? _time; + late final Outbound _out; + late final Inbound _in; + StreamSubscription? _sub; + final Set> _running = {}; + + /// 시계 보정 상태. + final ClockSync clockSync; + + /// 화면에 알릴 일 (친구 인사, 명함·대화방·대화가 들어옴). + Stream get events => _c.events.stream; + + Future start() async { + _sub ??= _c.transport.incoming.listen((e) { + final f = _receive(e); + _running.add(f); + unawaited(f.whenComplete(() => _running.remove(f))); + }); + await _c.transport.start(); + await refresh(); + } + + Future stop() async { + await _sub?.cancel(); + _sub = null; + await _c.transport.stop(); + } + + /// 시계를 맞추고, 보관함의 새 봉투를 가져오고, 못 보낸 것을 다시 보낸다. + Future refresh() async { + final t = _time; + if (t != null) await clockSync.syncWith(t); + await _c.transport.fetchPending(); + await idle(); + await _out.flush(); + } + + /// 지금 처리 중인 받은 봉투가 모두 끝날 때까지 기다린다 (시험·종료 직전에 쓴다). + Future idle() async { + while (_running.isNotEmpty) { + await Future.wait(_running.toList()); + } + } + + Future _receive(Envelope e) async { + final out = await _in.handle(e); + if (out == Outcome.applied && e.kind == 'hello') clockSync.syncWithPeer(e.stamp); + } + + /// 봉투 하나를 직접 처리한다 (시험·가져오기용). 연결에서 오는 봉투는 자동으로 처리된다. + Future receive(Envelope e) => _in.handle(e); + + /// 상대가 새로 알려졌거나 키를 알게 된 뒤 보류된 봉투를 다시 처리한다. + Future retryHeld() => _in.retryHeld(); + + Future sendHello(String peerPhone, {bool reply = false}) => + _out.hello(peerPhone, reply: reply); + + Future sendCard(ShareRecord share) => _out.card(share); + Future sendRoom(ShareRecord share) => _out.room(share); + Future sendRecovery(Person peer, String code) => _out.recovery(peer, code); + Future sendLocation(Person peer, double lat, double lng, int at) => _out.location(peer, lat, lng, at); + Future> sendMessage(Message m) => _out.message(m); + Future flushOutbox() => _out.flush(); +} diff --git a/lib/modules/sync/src/sync_event.dart b/lib/modules/sync/src/sync_event.dart new file mode 100644 index 0000000..e8a249f --- /dev/null +++ b/lib/modules/sync/src/sync_event.dart @@ -0,0 +1,21 @@ +/// 동기화가 화면 쪽에 알리는 일. +enum SyncEventType { hello, card, room, message, recovery, location } + +class SyncEvent { + const SyncEvent(this.type, {this.phone, this.name, this.publicKey, this.personId, this.refId, this.isReply = false, this.stamp = 0}); + + final SyncEventType type; + final String? phone; // hello: 보낸 사람 번호 + final String? name; // hello: 보낸 사람 이름 + final String? publicKey; // hello: 보낸 사람 공개 키 + final String? personId; // card·room·message: 보낸 사람 + final String? refId; // 명함 ID, 대화방 ID, 대화 ID + final bool isReply; // hello: 내 hello 에 대한 답인지 + final int stamp; +} + +/// 봉투 하나를 처리한 결과. +enum Outcome { applied, held, ignored } + +/// 보낸 결과. +enum SendResult { sent, queued, noKey, noTarget } diff --git a/lib/modules/sync/sync.dart b/lib/modules/sync/sync.dart new file mode 100644 index 0000000..1c2f174 --- /dev/null +++ b/lib/modules/sync/sync.dart @@ -0,0 +1,12 @@ +/// 동기화: 공유한 단위(명함 한 장, 대화방 하나)만 기기 사이에 복제한다. +/// +/// 저장소·전송·암호를 core 의 약속으로만 받는다. 충돌은 먼저 쓴 변경이 이기고, +/// 시계 오차는 서버 시각(없으면 같은 네트워크 상대)으로 바로잡는다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/clock_sync.dart'; +export 'src/conflict.dart'; +export 'src/outbound.dart' show heldRecoveryKey, heldRecoveryIndexKey, recoveryUnit, friendLocationKey, friendLocationIndexKey, locationUnit; +export 'src/sync_engine.dart'; +export 'src/sync_event.dart'; diff --git a/lib/modules/transport/src/composite_transport.dart b/lib/modules/transport/src/composite_transport.dart new file mode 100644 index 0000000..e4a5f64 --- /dev/null +++ b/lib/modules/transport/src/composite_transport.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:friendly_map/modules/core/core.dart'; + +/// 여러 연결 방식을 순서대로 시도하는 묶음 (예: 같은 네트워크 직접 연결 → 외부 보관함). +/// 앞의 방식으로 닿으면 거기서 멈춘다. +class CompositeTransport implements Transport { + CompositeTransport(this._parts); + + final List _parts; + final StreamController _in = StreamController.broadcast(sync: true); + final List> _subs = []; + + @override + Stream get incoming => _in.stream; + + @override + Future start() async { + for (final p in _parts) { + _subs.add(p.incoming.listen(_in.add)); + await p.start(); + } + } + + @override + Future stop() async { + for (final s in _subs) { + await s.cancel(); + } + _subs.clear(); + for (final p in _parts) { + await p.stop(); + } + } + + @override + Future send(Envelope envelope) async { + for (final p in _parts) { + try { + if (await p.send(envelope)) return true; + } catch (_) { + // 이 방식이 실패하면 다음 방식으로 넘어간다. + } + } + return false; + } + + @override + Future fetchPending() async { + var total = 0; + for (final p in _parts) { + try { + total += await p.fetchPending(); + } catch (_) {} + } + return total; + } +} diff --git a/lib/modules/transport/src/direct_transport.dart b/lib/modules/transport/src/direct_transport.dart new file mode 100644 index 0000000..bc11aab --- /dev/null +++ b/lib/modules/transport/src/direct_transport.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:friendly_map/modules/core/core.dart'; + +/// 같은 네트워크에 있는 상대의 주소. +class DirectAddress { + const DirectAddress(this.host, this.port); + final String host; + final int port; +} + +/// 상대 전화번호로 같은 네트워크의 주소를 찾아 주는 함수 (발견 모듈이 채워 준다). +typedef AddressResolver = Future Function(String phone); + +/// 같은 네트워크의 기기끼리 서버 없이 바로 주고받는다. 모든 기기가 서버 겸 클라이언트다. +class DirectTransport implements Transport { + DirectTransport({required this.resolve, this.port = 0, this.timeout = const Duration(seconds: 4)}); + + final AddressResolver resolve; + final int port; + final Duration timeout; + + HttpServer? _server; + final StreamController _in = StreamController.broadcast(sync: true); + final HttpClient _http = HttpClient(); + + /// 시작한 뒤 실제로 열린 포트 (발견 모듈이 이웃에게 알릴 때 쓴다). + int get boundPort => _server?.port ?? 0; + + @override + Stream get incoming => _in.stream; + + @override + Future start() async { + if (_server != null) return; + final s = await HttpServer.bind(InternetAddress.anyIPv4, port); + _server = s; + s.listen((req) async { + try { + if (req.method == 'POST' && req.uri.path == '/fm/envelope') { + final text = await utf8.decodeStream(req); + _in.add(Envelope.decode(text)); + req.response.statusCode = 200; + } else { + req.response.statusCode = 404; + } + } catch (_) { + req.response.statusCode = 400; + } + await req.response.close(); + }); + } + + @override + Future stop() async { + await _server?.close(force: true); + _server = null; + _http.close(force: true); + } + + @override + Future send(Envelope envelope) async { + final addr = await resolve(envelope.to); + if (addr == null) return false; + try { + final req = await _http + .postUrl(Uri(scheme: 'http', host: addr.host, port: addr.port, path: '/fm/envelope')) + .timeout(timeout); + req.add(utf8.encode(envelope.encode())); + final res = await req.close().timeout(timeout); + await res.drain(); + return res.statusCode == 200; + } on Object { + return false; + } + } + + /// 직접 연결에는 쌓아 두는 곳이 없다. + @override + Future fetchPending() async => 0; +} diff --git a/lib/modules/transport/src/late_transport.dart b/lib/modules/transport/src/late_transport.dart new file mode 100644 index 0000000..83d69f1 --- /dev/null +++ b/lib/modules/transport/src/late_transport.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:friendly_map/modules/core/core.dart'; + +/// 시작하는 순간에야 만들 수 있는 연결(예: 내 전화번호가 정해져야 열리는 보관함)을 미리 자리만 잡아 두는 껍데기. +/// +/// [build] 는 [start] 때 한 번 불린다. null 을 돌려주면 연결 없이 동작한다 (보내기는 실패, 받기는 없음). +/// 기준 시각도 안쪽 연결이 [TimeSource] 이면 그대로 전해 준다. +class LateTransport implements Transport, TimeSource { + LateTransport(this._build); + + final Future Function() _build; + final StreamController _in = StreamController.broadcast(sync: true); + Transport? _inner; + StreamSubscription? _sub; + + @override + Stream get incoming => _in.stream; + + @override + Future start() async { + if (_inner != null) return; + final t = await _build(); + if (t == null) return; + _inner = t; + _sub = t.incoming.listen(_in.add); + await t.start(); + } + + @override + Future stop() async { + await _sub?.cancel(); + _sub = null; + await _inner?.stop(); + _inner = null; + } + + @override + Future send(Envelope envelope) async => await _inner?.send(envelope) ?? false; + + @override + Future fetchPending() async => await _inner?.fetchPending() ?? 0; + + @override + Future referenceStamp() async { + final t = _inner; + return t is TimeSource ? await (t as TimeSource).referenceStamp() : null; + } +} diff --git a/lib/modules/transport/src/mailbox_transport.dart b/lib/modules/transport/src/mailbox_transport.dart new file mode 100644 index 0000000..ca648ba --- /dev/null +++ b/lib/modules/transport/src/mailbox_transport.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:friendly_map/modules/core/core.dart'; + +/// 외부 보관함 서버로 봉투를 주고받는다. 보관함은 잠긴 봉투를 잠시 맡아 두기만 한다. +/// +/// 서버 쪽 약속 (server/mailbox_server.py): +/// POST /v1/send 봉투 JSON → {"ok":true} +/// GET /v1/fetch?to=<번호> → {"items":[봉투 JSON ...]} (헤더 X-Fm-Token) +/// POST /v1/ack {"to":..,"ids":[..]} → {"ok":true} (헤더 X-Fm-Token) +/// GET /v1/time → {"stamp": 1/100초} +class MailboxTransport implements Transport, TimeSource { + MailboxTransport({ + required this.baseUrl, + required this.myPhone, + required this.token, + this.timeout = const Duration(seconds: 8), + }); + + final String baseUrl; + final String myPhone; + + /// 이 기기가 보관함에서 내 우편함을 열 때 내는 비밀값 (처음 쓴 기기에 묶인다). + final String token; + final Duration timeout; + + final StreamController _in = StreamController.broadcast(sync: true); + final HttpClient _http = HttpClient(); + + @override + Stream get incoming => _in.stream; + + @override + Future start() async {} + + @override + Future stop() async => _http.close(force: true); + + Future?> _call(String method, String path, + {Object? body, Map? query, bool auth = false}) async { + try { + final uri = Uri.parse('$baseUrl$path').replace(queryParameters: query); + final req = await _http.openUrl(method, uri).timeout(timeout); + req.headers.contentType = ContentType.json; + if (auth) req.headers.set('X-Fm-Token', token); + final bytes = utf8.encode(body == null ? '{}' : jsonEncode(body)); + if (method == 'POST') { + req.contentLength = bytes.length; // 서버가 길이를 요구한다 + req.add(bytes); + } + final res = await req.close().timeout(timeout); + final text = await utf8.decodeStream(res).timeout(timeout); + if (res.statusCode != 200) return null; + return (jsonDecode(text) as Map).cast(); + } on Object { + return null; // 연결 실패는 "못 보냄"으로 다룬다. + } + } + + @override + Future send(Envelope envelope) async { + final r = await _call('POST', '/v1/send', body: envelope.toJson()); + return r != null && r['ok'] == true; + } + + @override + Future fetchPending() async { + final r = await _call('GET', '/v1/fetch', query: {'to': myPhone}, auth: true); + if (r == null) return 0; + final items = (r['items'] as List? ?? const []).cast(); + final ids = []; + for (final raw in items) { + try { + final e = Envelope.fromJson((raw as Map).cast()); + ids.add(e.id); + _in.add(e); + } catch (_) { + // 읽을 수 없는 봉투는 건너뛴다. + } + } + if (ids.isNotEmpty) { + await _call('POST', '/v1/ack', body: {'to': myPhone, 'ids': ids}, auth: true); + } + return ids.length; + } + + @override + Future referenceStamp() async { + final r = await _call('GET', '/v1/time'); + final v = r?['stamp']; + return v is int ? v : null; + } +} diff --git a/lib/modules/transport/src/mailbox_verifier.dart b/lib/modules/transport/src/mailbox_verifier.dart new file mode 100644 index 0000000..568af51 --- /dev/null +++ b/lib/modules/transport/src/mailbox_verifier.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:friendly_map/modules/core/core.dart'; + +/// 보관함 서버로 문자 확인을 하는 [PhoneVerifier]. 맞게 확인하면 서버가 이 기기의 토큰을 내 번호에 묶는다. +class MailboxVerifier implements PhoneVerifier { + MailboxVerifier({required this.baseUrl, required this.myPhone, required this.token, this.timeout = const Duration(seconds: 10)}); + + final String baseUrl; + final String myPhone; + final String token; + final Duration timeout; + + Future _post(String path, Map body) async { + final http = HttpClient(); + try { + final req = await http.postUrl(Uri.parse('$baseUrl$path')).timeout(timeout); + req.headers.contentType = ContentType.json; + final bytes = utf8.encode(jsonEncode(body)); + req.contentLength = bytes.length; + req.add(bytes); + final res = await req.close().timeout(timeout); + await res.drain(); + return res.statusCode; + } on Object { + return -1; + } finally { + http.close(force: true); + } + } + + @override + Future requestCode() async { + final code = await _post('/v1/verify/start', {'phone': myPhone}); + return switch (code) { + 200 => VerifyStart.sent, + 429 => VerifyStart.rateLimited, + 400 => VerifyStart.unavailable, // 서버가 문자 확인을 쓰지 않는다 + _ => VerifyStart.failed, + }; + } + + @override + Future confirm(String code) async => + await _post('/v1/verify/confirm', {'phone': myPhone, 'code': code.trim(), 'token': token}) == 200; +} + +/// 내 번호·보관함 주소가 정해진 뒤에야 만들 수 있는 확인기를 미리 자리만 잡아 두는 껍데기. +/// [build] 가 null 을 돌려주면(보관함 주소 없음 등) 항상 "쓸 수 없음"이다. +class LatePhoneVerifier implements PhoneVerifier { + LatePhoneVerifier(this._build); + + final Future Function() _build; + + @override + Future requestCode() async => await (await _build())?.requestCode() ?? VerifyStart.unavailable; + + @override + Future confirm(String code) async => await (await _build())?.confirm(code) ?? false; +} diff --git a/lib/modules/transport/src/memory_transport.dart b/lib/modules/transport/src/memory_transport.dart new file mode 100644 index 0000000..7b61fc7 --- /dev/null +++ b/lib/modules/transport/src/memory_transport.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:friendly_map/modules/core/core.dart'; + +/// 메모리 안에서 여러 기기를 흉내 내는 연결망 (시험·화면 미리보기용). +/// 받는 사람이 꺼져 있으면 보관함처럼 쌓아 두었다가 [MemoryTransport.fetchPending] 으로 가져간다. +class MemoryHub { + final Map _online = {}; + final Map> _mailbox = {}; + + /// 지금까지 오간 봉투 수 (시험에서 확인용). + int delivered = 0; + + /// 보관함에 쌓인 것을 쓰지 않는 직접 연결만 흉내 내려면 false. + bool mailboxEnabled = true; + + bool _deliver(Envelope e) { + final target = _online[e.to]; + if (target != null && target._started) { + delivered++; + target._in.add(e); + return true; + } + if (!mailboxEnabled) return false; + _mailbox.putIfAbsent(e.to, () => []).add(e); + return true; + } +} + +class MemoryTransport implements Transport { + MemoryTransport(this.phone, this._hub) { + _hub._online[phone] = this; + } + + final String phone; + final MemoryHub _hub; + final StreamController _in = StreamController.broadcast(sync: true); + bool _started = false; + + @override + Stream get incoming => _in.stream; + + @override + Future start() async => _started = true; + + @override + Future stop() async => _started = false; + + @override + Future send(Envelope envelope) async => _hub._deliver(envelope); + + @override + Future fetchPending() async { + final items = _hub._mailbox.remove(phone) ?? const []; + for (final e in items) { + _in.add(e); + } + return items.length; + } +} diff --git a/lib/modules/transport/transport.dart b/lib/modules/transport/transport.dart new file mode 100644 index 0000000..2c25f9b --- /dev/null +++ b/lib/modules/transport/transport.dart @@ -0,0 +1,13 @@ +/// 전송: 봉투를 주고받는 여러 방식 (메모리, 같은 네트워크 직접, 외부 보관함, 묶음) +/// +/// core 의 Transport 약속을 지키는 구현들이다. 하나를 빼거나 새로 끼워도 +/// Transport 약속만 쓰는 모듈(동기화)은 바뀌지 않는다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/composite_transport.dart'; +export 'src/direct_transport.dart'; +export 'src/late_transport.dart'; +export 'src/mailbox_verifier.dart'; +export 'src/mailbox_transport.dart'; +export 'src/memory_transport.dart'; diff --git a/lib/modules/ui/src/app_scope.dart b/lib/modules/ui/src/app_scope.dart new file mode 100644 index 0000000..04783f3 --- /dev/null +++ b/lib/modules/ui/src/app_scope.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/platform/platform_ui.dart'; + +import 'bootstrap.dart'; + +/// 화면 아래쪽 어디서든 앱 내부와 기기 부품을 꺼내 쓰는 통로. +class AppScope extends InheritedWidget { + const AppScope({ + super.key, + required this.app, + required this.bootstrap, + required super.child, + this.qr, + this.motion, + }); + + final FriendlyApp app; + final Bootstrap bootstrap; + final QrScannerViewFactory? qr; // 카메라가 없는 환경(미리보기·시험)에서는 null + final MotionSensor? motion; + + static AppScope of(BuildContext context) { + final s = context.dependOnInheritedWidgetOfExactType(); + assert(s != null, 'AppScope 가 위젯 트리 위쪽에 없습니다'); + return s!; + } + + @override + bool updateShouldNotify(AppScope old) => old.app != app; +} diff --git a/lib/modules/ui/src/bootstrap.dart b/lib/modules/ui/src/bootstrap.dart new file mode 100644 index 0000000..b543105 --- /dev/null +++ b/lib/modules/ui/src/bootstrap.dart @@ -0,0 +1,23 @@ +import 'package:friendly_map/modules/app/app.dart'; + +/// 앱이 열리기 전의 일(복구 코드 만들기·복구, 앱 내부 열기)을 화면에 이어 주는 약속. +/// 키·저장소를 어떻게 여는지는 조립하는 쪽(main)이 정해서 구현한다. 화면은 모른다. +abstract class Bootstrap { + /// 이 기기에 키가 이미 있는지. + Future hasKeys(); + + /// 새 복구 코드를 만들고 12단어를 돌려준다. + Future createKeys(); + + /// 12단어 또는 친구들이 준 조각 글자들(한 줄에 하나)로 복구한다. 올바르지 않으면 false. + Future restoreKeys(String phrase); + + /// 키로 저장소를 열어 앱 내부를 만든다. + Future openApp(); + + /// 복구 코드를 [n] 조각으로 나눈다 (그중 [k] 조각이면 복구). 친구에게 맡길 때 쓴다. + Future> splitRecovery(int n, int k); + + /// 설정에서 복구 코드를 다시 보여 줄 때. 없으면 null. + Future recoveryPhrase(); +} diff --git a/lib/modules/ui/src/friendly_map_app.dart b/lib/modules/ui/src/friendly_map_app.dart new file mode 100644 index 0000000..c6fec97 --- /dev/null +++ b/lib/modules/ui/src/friendly_map_app.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/platform/platform_ui.dart'; + +import 'app_scope.dart'; +import 'bootstrap.dart'; +import 'startup_gate.dart'; +import 'theme.dart'; + +/// 앱의 뿌리 화면. 조립하는 쪽(main)이 [bootstrap] 과 기기 부품을 넘겨 준다. +class FriendlyMapApp extends StatefulWidget { + const FriendlyMapApp({super.key, required this.bootstrap, this.qr, this.motion}); + + final Bootstrap bootstrap; + final QrScannerViewFactory? qr; + final MotionSensor? motion; + + @override + State createState() => _FriendlyMapAppState(); +} + +class _FriendlyMapAppState extends State { + final GlobalKey _routes = GlobalKey(); + FriendlyApp? _app; + + @override + void dispose() { + _app?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Friendly Map', + debugShowCheckedModeBanner: false, + theme: buildTheme(), + // 새로 열리는 화면·대화상자도 AppScope 아래에 오도록 MaterialApp 바깥쪽에서 씌운다. + builder: (context, child) { + final body = KeyedSubtree(key: _routes, child: child ?? const SizedBox.shrink()); + final app = _app; + if (app == null) return body; + return AppScope(app: app, bootstrap: widget.bootstrap, qr: widget.qr, motion: widget.motion, child: body); + }, + home: StartupGate(bootstrap: widget.bootstrap, onOpened: (app) => setState(() => _app = app)), + ); + } +} diff --git a/lib/modules/ui/src/screens/add_friend_screen.dart b/lib/modules/ui/src/screens/add_friend_screen.dart new file mode 100644 index 0000000..4bb2cca --- /dev/null +++ b/lib/modules/ui/src/screens/add_friend_screen.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'qr_screens.dart'; + +/// 새 친구 추가: 전화번호로 등록하고 인사를 보낸다. 이미 아는 사람과 겹치면 같은 사람인지 묻는다. +class AddFriendScreen extends StatefulWidget { + const AddFriendScreen({super.key}); + + @override + State createState() => _AddFriendScreenState(); +} + +class _AddFriendScreenState extends State { + final _phone = TextEditingController(); + final _name = TextEditingController(); + String? _error; + bool _busy = false; + + @override + void dispose() { + _phone.dispose(); + _name.dispose(); + super.dispose(); + } + + Future _submit() async { + final app = AppScope.of(context).app; + setState(() { + _busy = true; + _error = null; + }); + final check = await app.friends.check(_phone.text, _name.text); + if (!mounted) return; + if (check.error != null) { + setState(() { + _busy = false; + _error = check.error; + }); + return; + } + final phone = check.phone!; + final match = check.match!; + String? samePersonId; + if (match.kind == MatchKind.nameDiffers || match.kind == MatchKind.phoneDiffers) { + final who = match.candidates.first; + final choice = await _askSame(match.kind, who); + if (!mounted) return; + if (choice == null) { + setState(() => _busy = false); + return; + } + if (choice) samePersonId = who.id; + } else if (match.kind == MatchKind.same) { + samePersonId = match.candidates.first.id; + } + final p = await app.friends.add(phone, _name.text, samePersonId: samePersonId); + if (!mounted) return; + setState(() => _busy = false); + if (p == null) { + setState(() => _error = '등록하지 못했어요. 번호를 확인해 주세요.'); + return; + } + showNote(context, '${p.name}님에게 친구 요청을 보냈어요.'); + Navigator.pop(context); + } + + /// true: 같은 사람(번호나 이름이 바뀜), false: 다른 사람, null: 취소. + Future _askSame(MatchKind kind, Person who) { + final nameDiffers = kind == MatchKind.nameDiffers; + return showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('이미 아는 분인가요?'), + content: Text(nameDiffers + ? '이 번호는 ${who.name}님으로 등록되어 있어요.\n같은 분이 이름을 바꾼 건가요?' + : '${who.name}님이 이미 있어요 (${who.phone}).\n같은 분이 번호를 바꾼 건가요?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(c), child: const Text('취소')), + if (!nameDiffers) TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('다른 사람이에요')), + TextButton(onPressed: () => Navigator.pop(c, true), child: const Text('같은 분이에요')), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('새 친구')), + body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column(children: [ + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(labelText: '전화번호', hintText: '010-1234-5678'), + ), + const SizedBox(height: 12), + TextField(controller: _name, decoration: const InputDecoration(labelText: '이름')), + if (_error != null) + Padding(padding: const EdgeInsets.only(top: 8), child: Text(_error!, style: const TextStyle(color: Pal.danger))), + const SizedBox(height: 16), + SizedBox(width: double.infinity, height: 52, child: FilledButton(onPressed: _busy ? null : _submit, child: const Text('친구 요청 보내기'))), + ]), + ), + const SectionHeader('QR 로 추가'), + Panel( + onTap: () => pushPage(context, const ScanQrScreen()), + child: const Row(children: [Icon(Icons.qr_code_scanner, color: Pal.accent), SizedBox(width: 12), Expanded(child: Text('상대의 QR 읽기')), Icon(Icons.chevron_right, color: Pal.muted)]), + ), + Panel( + onTap: () => pushPage(context, const MyQrScreen()), + child: const Row(children: [Icon(Icons.qr_code_2, color: Pal.accent), SizedBox(width: 12), Expanded(child: Text('내 QR 보여주기')), Icon(Icons.chevron_right, color: Pal.muted)]), + ), + const SectionHeader('같은 네트워크의 사람'), + AsyncView>( + load: (app) => app.friends.nearby(), + builder: (context, near) { + if (near.isEmpty) return const EmptyNote('근처에서 Friendly Map 을 켠 사람이 보이면 여기에 나타나요.'); + return Column(children: [ + for (final n in near) + Panel( + child: Row(children: [ + Avatar(n.known?.name ?? n.phone, size: 40), + const SizedBox(width: 12), + Expanded(child: Text(n.known?.name ?? n.phone)), + if (n.known == null) + TextButton( + onPressed: () async { + final app = AppScope.of(context).app; + await app.friends.addNearby(n.phone); + if (context.mounted) showNote(context, '친구 요청을 보냈어요.'); + }, + child: const Text('추가'), + ) + else + const Text('친구', style: TextStyle(color: Pal.muted)), + ]), + ), + ]); + }, + ), + ]), + ); + } +} diff --git a/lib/modules/ui/src/screens/archive_screen.dart b/lib/modules/ui/src/screens/archive_screen.dart new file mode 100644 index 0000000..1d3f2fb --- /dev/null +++ b/lib/modules/ui/src/screens/archive_screen.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; + +class _ArchiveData { + const _ArchiveData(this.span, this.advice); + final (int, int)? span; + final ArchiveAdvice advice; +} + +/// 별도 저장: 지우지 않고 오래된 대화를 별도 저장으로 나눈다. 나눌 범위는 직접 고른다. +class ArchiveScreen extends StatefulWidget { + const ArchiveScreen({super.key, required this.roomId}); + + final String roomId; + + @override + State createState() => _ArchiveScreenState(); +} + +class _ArchiveScreenState extends State { + RangeValues? _range; + + Future<_ArchiveData> _load(FriendlyApp app) async => + _ArchiveData(await app.settings.selectableSpan(widget.roomId), await app.settings.adviseRoom(widget.roomId)); + + Future _archive(int from, int to) async { + final ok = await askConfirm(context, '별도 저장으로 나눌까요?', + '고른 기간의 대화가 별도 저장으로 옮겨져요. 지워지는 것은 아니고, 언제든 되돌릴 수 있어요.', + ok: '나누기'); + if (!ok || !mounted) return; + final n = await AppScope.of(context).app.settings.archiveRange(widget.roomId, from, to); + if (!mounted) return; + showNote(context, '$n건을 별도 저장으로 나눴어요.'); + setState(() => _range = null); + } + + String _day(double v) { + final d = DateTime.fromMillisecondsSinceEpoch(v.round() * 10); + return '${d.year}.${d.month}.${d.day}'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('별도 저장')), + body: AsyncView<_ArchiveData>( + load: _load, + builder: (context, d) { + final span = d.span; + if (span == null) return const EmptyNote('나눌 대화가 없어요.'); + final lo = span.$1.toDouble(), hi = span.$2.toDouble(); + final r = _range ?? RangeValues(lo, hi); + final kb = d.advice.textBytes ~/ 1024; + return ListView(padding: const EdgeInsets.all(16), children: [ + Text('지금 이 방의 대화 크기: 약 $kb KB', style: const TextStyle(color: Pal.muted)), + const SizedBox(height: 16), + const Text('나눌 기간', style: TextStyle(fontWeight: FontWeight.w700)), + RangeSlider( + values: RangeValues(r.start.clamp(lo, hi), r.end.clamp(lo, hi)), + min: lo, + max: hi == lo ? lo + 1 : hi, + onChanged: (v) => setState(() => _range = v), + ), + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(_day(r.start)), Text(_day(r.end))]), + const SizedBox(height: 20), + SizedBox( + height: 52, + child: FilledButton(onPressed: () => _archive(r.start.round(), r.end.round()), child: const Text('이 기간을 별도 저장으로 나누기')), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/card_edit_screen.dart b/lib/modules/ui/src/screens/card_edit_screen.dart new file mode 100644 index 0000000..f77b974 --- /dev/null +++ b/lib/modules/ui/src/screens/card_edit_screen.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/layout/layout.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; + +class _EditData { + const _EditData(this.card, this.template, this.shares); + final MyCard? card; + final CardTemplate? template; + final List shares; +} + +/// 명함 고치기. 명함 틀의 항목대로 입력칸이 나온다. +class CardEditScreen extends StatelessWidget { + const CardEditScreen({super.key, required this.cardId}); + + final String cardId; + + Future<_EditData> _load(FriendlyApp app) async { + final card = (await app.cards.mine()).where((c) => c.id == cardId).firstOrNull; + CardTemplate? tpl; + for (final t in await app.layout.templates()) { + if (t.id == card?.templateId) tpl = t; + tpl ??= t.kind == card?.kind ? t : null; + } + return _EditData(card, tpl, card == null ? const [] : await app.cards.sharesOf(card.id)); + } + + @override + Widget build(BuildContext context) { + return AsyncView<_EditData>( + load: _load, + builder: (context, d) { + final card = d.card; + if (card == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 명함이에요.')); + return _Form(card: card, template: d.template, shareCount: d.shares.length); + }, + ); + } +} + +class _Form extends StatefulWidget { + const _Form({required this.card, required this.template, required this.shareCount}); + + final MyCard card; + final CardTemplate? template; + final int shareCount; + + @override + State<_Form> createState() => _FormState(); +} + +class _FormState extends State<_Form> { + late final TextEditingController _title = TextEditingController(text: widget.card.title); + final Map _ctl = {}; + final Map _labels = {}; + late bool _default = widget.card.shareDefault; + + @override + void initState() { + super.initState(); + final values = decodeFields(widget.card.fieldsJson); + for (final f in widget.template?.fields ?? const []) { + _labels[f.key] = f.label; + _ctl[f.key] = TextEditingController(text: values[f.key] ?? ''); + } + for (final e in values.entries) { + if (_ctl.containsKey(e.key)) continue; // 틀에 없는 항목도 잃지 않게 보여 준다 + _labels[e.key] = e.key; + _ctl[e.key] = TextEditingController(text: e.value); + } + } + + @override + void dispose() { + _title.dispose(); + for (final c in _ctl.values) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + final app = AppScope.of(context).app; + await app.cards.edit( + widget.card.id, + title: _title.text.trim().isEmpty ? widget.card.title : _title.text.trim(), + fields: {for (final e in _ctl.entries) e.key: e.value.text.trim()}, + shareDefault: _default, + ); + if (!mounted) return; + showNote(context, widget.shareCount > 0 ? '저장했어요. 상대에게는 "다시 보내기"를 눌러야 가요.' : '저장했어요.'); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row(children: [ + KindChip(widget.card.kind, CardKinds.label(widget.card.kind)), + const SizedBox(width: 8), + const Text('명함 고치기'), + ]), + actions: [TextButton(onPressed: _save, child: const Text('저장'))], + ), + body: ListView(padding: const EdgeInsets.all(16), children: [ + TextField(controller: _title, decoration: const InputDecoration(labelText: '명함 이름')), + const SizedBox(height: 14), + for (final e in _ctl.entries) ...[ + TextField( + controller: e.value, + decoration: InputDecoration(labelText: _labels[e.key] ?? e.key, hintText: _hint(e.key)), + ), + const SizedBox(height: 12), + ], + const SizedBox(height: 4), + ToggleRow( + title: '새 친구에게 기본으로 공유', + subtitle: '친구가 되면 이 명함을 자동으로 보내요', + value: _default, + onChanged: (v) => setState(() => _default = v), + ), + if (widget.shareCount > 0) + Text('지금 ${widget.shareCount}명에게 공유 중이에요. 고친 내용은 "다시 보내기"를 눌러야 상대에게 가요.', + style: const TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), + ]), + ); + } + + String? _hint(String key) { + for (final fd in widget.template?.fields ?? const []) { + if (fd.key == key && fd.hint.isNotEmpty) return fd.hint; + } + return null; + } +} diff --git a/lib/modules/ui/src/screens/card_wallet_screen.dart b/lib/modules/ui/src/screens/card_wallet_screen.dart new file mode 100644 index 0000000..6039f39 --- /dev/null +++ b/lib/modules/ui/src/screens/card_wallet_screen.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/labels.dart'; +import 'card_edit_screen.dart'; +import 'share_screen.dart'; +import 'template_gallery_screen.dart'; + +class _WalletData { + const _WalletData(this.cards, this.shares, this.resend, this.people, this.labels); + final List cards; + final Map> shares; + final List resend; + final Map people; + final Map labels; +} + +/// 내 명함첩: 용도별 여러 장. 길게 눌러 끌면 순서가 바뀌고, 고친 명함은 "다시 보내기"로 상대에게 보낸다. +class CardWalletScreen extends StatelessWidget { + const CardWalletScreen({super.key}); + + Future<_WalletData> _load(FriendlyApp app) async { + final cards = await app.cards.mine(); + return _WalletData( + cards, + {for (final c in cards) c.id: await app.cards.sharesOf(c.id)}, + await app.cards.needingResend(), + {for (final p in await app.friends.people()) p.id: p}, + await fieldLabels(app), + ); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return Scaffold( + appBar: AppBar( + title: const Text('내 명함', style: TextStyle(fontWeight: FontWeight.w700)), + actions: [ + IconButton(tooltip: '명함 공유', onPressed: () => pushPage(context, const ShareScreen()), icon: const Icon(Icons.ios_share)), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => pushPage(context, const TemplateGalleryScreen()), + icon: const Icon(Icons.add), + label: const Text('명함 추가'), + ), + body: AsyncView<_WalletData>( + load: _load, + builder: (context, d) { + return ListView(padding: const EdgeInsets.only(bottom: 100), children: [ + if (d.resend.isNotEmpty) _ResendBanner(d), + if (d.cards.isEmpty) const EmptyNote('명함이 없어요. 아래 버튼으로 만들어 보세요.'), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text('길게 눌러 끌면 순서를 바꿀 수 있어요', style: TextStyle(fontSize: 11, color: Pal.muted.withValues(alpha: 0.8))), + ), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: true, + onReorderItem: (from, to) => app.cards.move(d.cards[from].id, to), + children: [ + for (final c in d.cards) + _CardTile(key: ValueKey(c.id), card: c, shares: d.shares[c.id] ?? const [], people: d.people, labels: d.labels), + ], + ), + ]); + }, + ), + ); + } +} + +class _ResendBanner extends StatelessWidget { + const _ResendBanner(this.d); + + final _WalletData d; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('고친 명함을 아직 보내지 않았어요', style: TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('상대에게는 "다시 보내기"를 눌러야 새 내용이 가요.', style: TextStyle(fontSize: 12, color: Pal.muted)), + const SizedBox(height: 8), + for (final s in d.resend) + Row(children: [ + Expanded(child: Text('${d.people[s.peerId]?.name ?? '?'} · ${_title(s)}', overflow: TextOverflow.ellipsis)), + TextButton( + onPressed: () async { + final ok = await app.cards.resend(s.id); + if (context.mounted) showNote(context, ok ? '다시 보냈어요.' : '보내지 못했어요.'); + }, + child: const Text('다시 보내기'), + ), + ]), + ]), + ); + } + + String _title(ShareRecord s) { + for (final c in d.cards) { + if (c.id == s.targetId) return c.title; + } + return '명함'; + } +} + +class _CardTile extends StatelessWidget { + const _CardTile({super.key, required this.card, required this.shares, required this.people, required this.labels}); + + final MyCard card; + final List shares; + final Map people; + final Map labels; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + final lines = filledFields(decodeFields(card.fieldsJson), labels).take(3).toList(); + final names = [for (final s in shares) people[s.peerId]?.name ?? '?']; + return Panel( + onTap: () => pushPage(context, CardEditScreen(cardId: card.id)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + KindChip(card.kind, CardKinds.label(card.kind)), + const SizedBox(width: 8), + Expanded(child: Text(card.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), overflow: TextOverflow.ellipsis)), + PopupMenuButton( + tooltip: '더 보기', + onSelected: (v) async { + if (v == 'edit') pushPage(context, CardEditScreen(cardId: card.id)); + if (v == 'share') pushPage(context, ShareScreen(initialCardId: card.id)); + if (v == 'delete') { + final ok = await askConfirm(context, '명함을 지울까요?', '이미 상대에게 간 사본은 상대 기기에 남아요. 이후 공유는 끝나요.', ok: '지우기'); + if (ok) await app.cards.delete(card.id); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('고치기')), + PopupMenuItem(value: 'share', child: Text('공유')), + PopupMenuItem(value: 'delete', child: Text('지우기')), + ], + ), + ]), + if (lines.isEmpty) const Text('내용을 채워 보세요', style: TextStyle(color: Pal.muted, fontSize: 13)), + for (final e in lines) Text('${e.key} ${e.value}', style: const TextStyle(fontSize: 13, height: 1.5)), + const SizedBox(height: 6), + Row(children: [ + const Icon(Icons.share_outlined, size: 14, color: Pal.muted), + const SizedBox(width: 6), + Expanded( + child: Text( + names.isEmpty ? '공유 안 함' : '${names.join(', ')} 에게 공유 중', + style: const TextStyle(fontSize: 12, color: Pal.muted), + overflow: TextOverflow.ellipsis, + ), + ), + if (card.shareDefault) const KindChip('family', '새 친구에게 기본 공유'), + ]), + ]), + ); + } +} diff --git a/lib/modules/ui/src/screens/chat_list_screen.dart b/lib/modules/ui/src/screens/chat_list_screen.dart new file mode 100644 index 0000000..4d2db4a --- /dev/null +++ b/lib/modules/ui/src/screens/chat_list_screen.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'add_friend_screen.dart'; +import 'chat_room_screen.dart'; + +/// 채팅 목록. +class ChatListScreen extends StatelessWidget { + const ChatListScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('채팅', style: TextStyle(fontWeight: FontWeight.w700)), + actions: [ + IconButton( + tooltip: '새 친구', + onPressed: () => pushPage(context, const AddFriendScreen()), + icon: const Icon(Icons.person_add_alt_1), + ), + ], + ), + floatingActionButton: FloatingActionButton( + tooltip: '새 대화', + onPressed: () => _newChat(context), + child: const Icon(Icons.chat_bubble_outline), + ), + body: AsyncView>( + load: (app) => app.chats.summaries(), + builder: (context, rooms) { + if (rooms.isEmpty) return const EmptyNote('아직 대화가 없어요.\n오른쪽 아래 버튼으로 친구와 대화를 시작해 보세요.'); + return ListView( + padding: const EdgeInsets.only(top: 8, bottom: 90), + children: [for (final r in rooms) _RoomTile(r)], + ); + }, + ), + ); + } + + Future _newChat(BuildContext context) async { + final app = AppScope.of(context).app; + final people = await app.friends.people(); + if (!context.mounted) return; + if (people.isEmpty) { + showNote(context, '먼저 친구를 추가해 주세요.'); + pushPage(context, const AddFriendScreen()); + return; + } + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => _NewChatSheet(people), + ); + } +} + +class _RoomTile extends StatelessWidget { + const _RoomTile(this.s); + + final RoomSummary s; + + @override + Widget build(BuildContext context) { + final r = s.room; + return Panel( + onTap: () => pushPage(context, ChatRoomScreen(roomId: r.id)), + child: Row(children: [ + Avatar(r.title, color: Pal.ofKind(r.contextKind)), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Flexible(child: Text(r.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))), + if (r.contextKind != CardKinds.personal) ...[ + const SizedBox(width: 8), + KindChip(r.contextKind, CardKinds.label(r.contextKind)), + ], + ]), + const SizedBox(height: 3), + Text(s.last?.body ?? '새 대화방', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, color: Pal.muted)), + ]), + ), + const SizedBox(width: 8), + Text(shortTime(s.last?.sentAt ?? r.updatedAt), style: const TextStyle(fontSize: 11, color: Pal.muted)), + ]), + ); + } +} + +/// 새 대화: 친구와 맥락(어떤 명함으로 하는 대화인지)을 고른다. +class _NewChatSheet extends StatefulWidget { + const _NewChatSheet(this.people); + + final List people; + + @override + State<_NewChatSheet> createState() => _NewChatSheetState(); +} + +class _NewChatSheetState extends State<_NewChatSheet> { + String _context = CardKinds.personal; + + Future _open(Person p) async { + final nav = Navigator.of(context); + final app = AppScope.of(context).app; + final room = await app.chats.openDirect(p.id, _context); + if (!mounted) return; + if (room == null) { + showNote(context, '${CardKinds.label(_context)} 대화는 ${CardKinds.label(_context)} 명함을 먼저 서로 공유해야 해요.'); + return; + } + nav.pop(); + if (!nav.mounted) return; + nav.push(MaterialPageRoute(builder: (_) => ChatRoomScreen(roomId: room.id))); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Column(children: [ + const Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Align(alignment: Alignment.centerLeft, child: Text('어떤 대화인가요?', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700))), + ), + SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + for (final k in CardKinds.all) + Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(CardKinds.label(k)), + selected: _context == k, + onSelected: (_) => setState(() => _context = k), + ), + ), + ], + ), + ), + const Divider(), + Expanded( + child: ListView(children: [ + for (final p in widget.people) + ListTile( + minTileHeight: 56, + leading: Avatar(p.name, size: 40), + title: Text(p.name), + subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + onTap: () => _open(p), + ), + ]), + ), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/chat_room_screen.dart b/lib/modules/ui/src/screens/chat_room_screen.dart new file mode 100644 index 0000000..fe7a38c --- /dev/null +++ b/lib/modules/ui/src/screens/chat_room_screen.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'archive_screen.dart'; + +class _RoomData { + const _RoomData(this.room, this.messages, this.advice); + final ChatRoom? room; + final List messages; + final ArchiveAdvice advice; +} + +/// 대화방. 맥락(개인·회사 …)을 이름표로 보여 주고, 별도 저장이 필요하면 알려 준다. +class ChatRoomScreen extends StatefulWidget { + const ChatRoomScreen({super.key, required this.roomId}); + + final String roomId; + + @override + State createState() => _ChatRoomScreenState(); +} + +class _ChatRoomScreenState extends State { + final _input = TextEditingController(); + + @override + void dispose() { + _input.dispose(); + super.dispose(); + } + + Future _send() async { + final text = _input.text; + if (text.trim().isEmpty) return; + _input.clear(); + await AppScope.of(context).app.chats.send(widget.roomId, text); + } + + Future<_RoomData> _load(FriendlyApp app) async { + final rooms = await app.chats.summaries(); + ChatRoom? room; + for (final s in rooms) { + if (s.room.id == widget.roomId) room = s.room; + } + return _RoomData(room, await app.chats.messages(widget.roomId, limit: 200), await app.settings.adviseRoom(widget.roomId)); + } + + @override + Widget build(BuildContext context) { + return AsyncView<_RoomData>( + load: _load, + builder: (context, d) { + final room = d.room; + return Scaffold( + appBar: AppBar( + title: Row(children: [ + Flexible(child: Text(room?.title ?? '대화', overflow: TextOverflow.ellipsis)), + if (room != null && room.contextKind != CardKinds.personal) ...[ + const SizedBox(width: 8), + KindChip(room.contextKind, CardKinds.label(room.contextKind)), + ], + ]), + ), + body: Column(children: [ + if (d.advice.recommended) + Panel( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 8), + onTap: () => pushPage(context, ArchiveScreen(roomId: widget.roomId)), + child: Row(children: [ + const Icon(Icons.inventory_2_outlined, color: Pal.accent), + const SizedBox(width: 12), + Expanded( + child: Text( + d.advice.overText ? '이 방의 대화가 많아요. 오래된 대화를 별도 저장으로 나눌까요?' : '기기 저장 공간이 부족해져요. 대화를 별도 저장으로 나눌까요?', + style: const TextStyle(fontSize: 13), + ), + ), + const Icon(Icons.chevron_right, color: Pal.muted), + ]), + ), + Expanded( + child: d.messages.isEmpty + ? const EmptyNote('첫 메시지를 보내 보세요.') + : ListView.builder( + reverse: true, + padding: const EdgeInsets.all(12), + itemCount: d.messages.length, + itemBuilder: (_, i) => _Bubble(d.messages[d.messages.length - 1 - i]), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 8, 8), + child: Row(children: [ + Expanded( + child: TextField( + controller: _input, + minLines: 1, + maxLines: 4, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration(hintText: '메시지', contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 10)), + ), + ), + const SizedBox(width: 6), + IconButton.filled(onPressed: _send, tooltip: '보내기', icon: const Icon(Icons.send)), + ]), + ), + ), + ]), + ); + }, + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble(this.m); + + final Message m; + + @override + Widget build(BuildContext context) { + final mine = m.senderId == ChatService.me; + return Align( + alignment: mine ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), + decoration: BoxDecoration( + color: mine ? Pal.accent : Pal.surface, + borderRadius: BorderRadius.circular(14), + border: mine ? null : Border.all(color: Pal.line), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text(m.body, style: TextStyle(color: mine ? Colors.black : Pal.text, height: 1.4)), + const SizedBox(height: 2), + Text(shortTime(m.sentAt), style: TextStyle(fontSize: 10, color: mine ? Colors.black54 : Pal.muted)), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/cluster_screen.dart b/lib/modules/ui/src/screens/cluster_screen.dart new file mode 100644 index 0000000..de65097 --- /dev/null +++ b/lib/modules/ui/src/screens/cluster_screen.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; + +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'person_screen.dart'; + +/// 같은 회사·학교·모임 사람들의 묶음. 받은 명함의 항목으로 자동으로 묶인다. +class ClusterScreen extends StatelessWidget { + const ClusterScreen({super.key, required this.kind}); + + final String kind; // company | school | group + + @override + Widget build(BuildContext context) { + final color = Pal.ofKind(kind); + return Scaffold( + appBar: AppBar(title: Text('${CardKinds.label(kind)} 사람들')), + body: AsyncView>( + load: (app) => app.network.clusters(kind), + builder: (context, clusters) { + if (clusters.isEmpty) return EmptyNote('${CardKinds.label(kind)} 명함을 받으면\n같은 곳 사람들이 여기에 묶여요.'); + return ListView(padding: const EdgeInsets.only(top: 8, bottom: 32), children: [ + for (final c in clusters) + Panel( + padding: EdgeInsets.zero, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + leading: Avatar(c.title, color: color), + title: Text(c.title, style: const TextStyle(fontWeight: FontWeight.w700)), + subtitle: Text('${c.people.length}명', style: const TextStyle(color: Pal.muted, fontSize: 12)), + children: [ + for (final p in c.people) + ListTile( + minTileHeight: 52, + leading: Avatar(p.name, size: 36, color: color), + title: Text(p.name), + onTap: () => pushPage(context, PersonScreen(personId: p.id)), + ), + ], + ), + ), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/discover_screen.dart b/lib/modules/ui/src/screens/discover_screen.dart new file mode 100644 index 0000000..6d36ec5 --- /dev/null +++ b/lib/modules/ui/src/screens/discover_screen.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'infer_screen.dart'; +import 'shake_screen.dart'; + +class _DiscoverData { + const _DiscoverData(this.requests, this.suggestions); + final List requests; + final List suggestions; +} + +/// 찾기: 받은 친구 요청, 자동 연동 추천, 흔들어서 명함 주고받기. +class DiscoverScreen extends StatelessWidget { + const DiscoverScreen({super.key}); + + Future<_DiscoverData> _load(FriendlyApp app) async => _DiscoverData(await app.friends.requests(), await app.network.suggestions()); + + Future _accept(BuildContext context, FriendRequest r) async { + final app = AppScope.of(context).app; + final check = await app.friends.check(r.phone, r.name); + String? same; + if (check.match != null && check.match!.kind == MatchKind.phoneDiffers) { + final who = check.match!.candidates.first; + if (!context.mounted) return; + final isSame = await askConfirm(context, '이미 아는 분인가요?', '${who.name}님(${who.phone})이 이미 있어요.\n같은 분이 번호를 바꾼 건가요?', ok: '같은 분이에요'); + if (isSame) same = who.id; + } + await app.friends.accept(r.phone, samePersonId: same); + if (context.mounted) showNote(context, '${r.name}님과 친구가 되었어요.'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('찾기', style: TextStyle(fontWeight: FontWeight.w700))), + body: AsyncView<_DiscoverData>( + load: _load, + builder: (context, d) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Panel( + onTap: () => pushPage(context, const ShakeScreen()), + child: const Row(children: [ + Icon(Icons.vibration, color: Pal.accent), + SizedBox(width: 12), + Expanded(child: Text('흔들어서 명함 주고받기')), + Icon(Icons.chevron_right, color: Pal.muted), + ]), + ), + const SectionHeader('받은 친구 요청'), + if (d.requests.isEmpty) const EmptyNote('새 요청이 없어요.'), + for (final r in d.requests) + Panel( + child: Row(children: [ + Avatar(r.name, size: 40), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(r.name, style: const TextStyle(fontWeight: FontWeight.w700)), + Text(r.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + ]), + ), + TextButton(onPressed: () => AppScope.of(context).app.friends.decline(r.phone), child: const Text('거절')), + FilledButton(onPressed: () => _accept(context, r), child: const Text('수락')), + ]), + ), + const SectionHeader('자동 연동 추천'), + if (d.suggestions.isEmpty) const EmptyNote('명함을 주고받으면 같은 학교·회사·모임 사람을 여기서 추천해요.'), + for (final s in d.suggestions) + Panel( + onTap: () => pushPage(context, InferScreen(personId: s.person.id)), + child: Row(children: [ + Avatar(s.person.name, size: 40), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(s.person.name, style: const TextStyle(fontWeight: FontWeight.w700)), + Text('${s.guess.relation} · ${s.guess.detail}', style: const TextStyle(fontSize: 12, color: Pal.muted)), + ]), + ), + Text('${s.guess.confidence}%', style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)), + const SizedBox(width: 4), + TextButton( + onPressed: () => AppScope.of(context).app.network.addLink(s.person.id, s.guess.relation, s.guess.detail), + child: const Text('확정'), + ), + ]), + ), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/family_screen.dart b/lib/modules/ui/src/screens/family_screen.dart new file mode 100644 index 0000000..fd40a34 --- /dev/null +++ b/lib/modules/ui/src/screens/family_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/person_picker.dart'; +import 'person_screen.dart'; + +/// 가족 관계를 세대 줄로 나눈다 (화면 배치용 단순 분류). +int _row(String relation) { + const up = ['부모', '아버지', '어머니', '아빠', '엄마', '할아버지', '할머니', '외할', '친할', '조부', '삼촌', '이모', '고모', '큰아버지', '작은아버지']; + const down = ['아들', '딸', '자녀', '손자', '손녀', '조카']; + if (up.any(relation.contains)) return 0; + if (down.any(relation.contains)) return 2; + return 1; +} + +/// 가계도: 윗세대 / 나와 같은 세대 / 아랫세대 줄로 가족을 보여 준다. +class FamilyScreen extends StatelessWidget { + const FamilyScreen({super.key}); + + Future _add(BuildContext context) async { + final app = AppScope.of(context).app; + final people = await app.friends.people(); + if (!context.mounted) return; + final p = await pickPerson(context, people, title: '가족으로 추가할 친구'); + if (p == null || !context.mounted) return; + final ctl = TextEditingController(); + final rel = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('${p.name}님은 나의…'), + content: TextField(controller: ctl, autofocus: true, decoration: const InputDecoration(hintText: '예: 어머니, 동생, 큰아버지')), + actions: [ + TextButton(onPressed: () => Navigator.pop(c), child: const Text('취소')), + TextButton(onPressed: () => Navigator.pop(c, ctl.text.trim()), child: const Text('추가')), + ], + ), + ); + ctl.dispose(); + if (rel == null || rel.isEmpty) return; + await app.network.addLink(p.id, '가족', rel); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('가족')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _add(context), + icon: const Icon(Icons.add), + label: const Text('가족 추가'), + ), + body: AsyncView>( + load: (app) => app.network.family(), + builder: (context, members) { + if (members.isEmpty) { + return const EmptyNote('가족 명함을 받거나, 친구를 가족으로 추가하면\n여기에 가계도가 만들어져요.'); + } + final rows = [[], [], []]; + for (final m in members) { + rows[_row(m.relation)].add(m); + } + return ListView(padding: const EdgeInsets.only(bottom: 100), children: [ + _GenerationRow('윗세대', rows[0]), + _GenerationRow('같은 세대', rows[1], showMe: true), + _GenerationRow('아랫세대', rows[2]), + ]); + }, + ), + ); + } +} + +class _GenerationRow extends StatelessWidget { + const _GenerationRow(this.title, this.members, {this.showMe = false}); + + final String title; + final List members; + final bool showMe; + + @override + Widget build(BuildContext context) { + if (members.isEmpty && !showMe) return const SizedBox.shrink(); + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SectionHeader(title), + SizedBox( + height: 104, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + children: [ + if (showMe) + const _Node(name: '나', relation: '', color: Pal.accent), + for (final m in members) + _Node( + name: m.person.name, + relation: m.relation, + color: Pal.accent, + onTap: () => pushPage(context, PersonScreen(personId: m.person.id)), + ), + ], + ), + ), + ]); + } +} + +class _Node extends StatelessWidget { + const _Node({required this.name, required this.relation, required this.color, this.onTap}); + + final String name; + final String relation; + final Color color; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: SizedBox( + width: 84, + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Avatar(name, size: 52, color: color), + const SizedBox(height: 6), + Text(name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + if (relation.isNotEmpty) Text(relation, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Pal.muted)), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/gathering_screen.dart b/lib/modules/ui/src/screens/gathering_screen.dart new file mode 100644 index 0000000..956db8a --- /dev/null +++ b/lib/modules/ui/src/screens/gathering_screen.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/events/events.dart'; +import 'package:friendly_map/modules/kinship/kinship.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'person_facts_screen.dart'; +import 'person_screen.dart'; + +class _Row { + const _Row(this.entry, this.facts); + final KinEntry entry; + final PersonFacts facts; +} + +class _Data { + const _Data(this.rows, this.soon, this.showDeceased, this.me); + final List<_Row> rows; + final List soon; + final bool showDeceased; + final KinPerson me; +} + +/// 가족 모임: 누가 누군지 한눈에. 이름을 눌러 촌수, 통화, 일정을 본다. +class GatheringScreen extends StatefulWidget { + const GatheringScreen({super.key}); + + @override + State createState() => _GatheringScreenState(); +} + +class _GatheringScreenState extends State { + String _q = ''; + + Future<_Data> _load(FriendlyApp app) async => _Data( + [for (final e in await app.kin.everyone()) _Row(e, await app.facts.get(e.person.id))], + await app.events.soon(), + await app.events.showDeceased(), + await app.kin.me(), + ); + + Future _add() async { + final app = AppScope.of(context).app; + final name = TextEditingController(); + final phone = TextEditingController(); + final year = TextEditingController(); + var gender = Gender.unknown; + var role = ''; + final ok = await showDialog( + context: context, + builder: (c) => StatefulBuilder( + builder: (c, set) => AlertDialog( + title: const Text('가족 추가'), + content: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField(controller: name, decoration: const InputDecoration(labelText: '이름')), + TextField(controller: phone, keyboardType: TextInputType.phone, decoration: const InputDecoration(labelText: '전화번호 (없으면 비워 두기)')), + TextField(controller: year, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '태어난 해 (선택)')), + const SizedBox(height: 10), + SegmentedButton( + segments: const [ + ButtonSegment(value: Gender.male, label: Text('남')), + ButtonSegment(value: Gender.female, label: Text('여')), + ButtonSegment(value: Gender.unknown, label: Text('모름')), + ], + selected: {gender}, + onSelectionChanged: (s) => set(() => gender = s.first), + ), + const SizedBox(height: 8), + Wrap(spacing: 6, children: [ + for (final r in const {'parent': '내 부모', 'child': '내 자녀', 'spouse': '내 배우자'}.entries) + ChoiceChip(label: Text(r.value), selected: role == r.key, onSelected: (v) => set(() => role = v ? r.key : '')), + ]), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('취소')), + TextButton(onPressed: () => Navigator.pop(c, true), child: const Text('추가')), + ], + ), + ), + ); + if (ok != true) return; + final p = await app.kin.addRelative(name.text, phone: phone.text, gender: gender, birthYear: int.tryParse(year.text.trim())); + if (!mounted) return; + if (p == null) { + showNote(context, '이름이나 전화번호를 확인해 주세요. 같은 번호가 이미 있을 수도 있어요.'); + return; + } + switch (role) { + case 'parent': + await app.kin.setParent(KinGraph.meId, p.id); + case 'child': + await app.kin.setParent(p.id, KinGraph.meId); + case 'spouse': + await app.kin.setSpouse(KinGraph.meId, p.id); + } + if (mounted) pushPage(context, PersonFactsScreen(personId: p.id)); + } + + Future _editMe() async { + final app = AppScope.of(context).app; + final cur = await app.kin.me(); + final year = TextEditingController(text: cur.birthYear?.toString() ?? ''); + var gender = cur.gender; + if (!mounted) return; + final ok = await showDialog( + context: context, + builder: (c) => StatefulBuilder( + builder: (c, set) => AlertDialog( + title: const Text('나의 정보'), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + SegmentedButton( + segments: const [ + ButtonSegment(value: Gender.male, label: Text('남')), + ButtonSegment(value: Gender.female, label: Text('여')), + ], + emptySelectionAllowed: true, + selected: {if (gender != Gender.unknown) gender}, + onSelectionChanged: (s) => set(() => gender = s.isEmpty ? Gender.unknown : s.first), + ), + TextField(controller: year, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '태어난 해')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('취소')), + TextButton(onPressed: () => Navigator.pop(c, true), child: const Text('저장')), + ], + ), + ), + ); + if (ok == true) await app.kin.setMe(gender: gender, birthYear: int.tryParse(year.text.trim())); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('가족 모임'), actions: [ + IconButton(tooltip: '나의 정보', onPressed: _editMe, icon: const Icon(Icons.person_outline)), + IconButton(tooltip: '가족 추가', onPressed: _add, icon: const Icon(Icons.person_add_alt)), + ]), + body: AsyncView<_Data>( + load: _load, + builder: (context, d) { + final q = _q.trim(); + final rows = d.rows.where((r) => q.isEmpty || r.entry.person.name.contains(q) || r.entry.relation.title.contains(q)).toList(); + return ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: TextField( + onChanged: (v) => setState(() => _q = v), + decoration: const InputDecoration(prefixIcon: Icon(Icons.search), hintText: '이름이나 호칭으로 찾기 (예: 당숙)'), + ), + ), + if (d.me.gender == Gender.unknown) + Panel(onTap: _editMe, child: const Text('먼저 "나의 정보"에서 성별과 태어난 해를 적어 주세요. 호칭이 정확해져요.')), + if (d.soon.isNotEmpty) ...[ + const SectionHeader('곧 있는 일정'), + for (final e in d.soon.take(5)) + Panel( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row(children: [ + Expanded(child: Text(e.text)), + Text(dayWord(e.upcoming.daysLeft), style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)), + ]), + ), + ], + SectionHeader('가족 ${rows.length}명'), + if (rows.isEmpty) const EmptyNote('아직 가족이 없어요.\n오른쪽 위 + 로 한 분씩 추가해 보세요.'), + for (final r in rows) + Panel( + onTap: () => pushPage(context, PersonScreen(personId: r.entry.person.id)), + child: Row(children: [ + Avatar(r.entry.person.name, size: 40), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${d.showDeceased && r.facts.deceased ? '故 ' : ''}${r.entry.person.name}', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + Text(r.entry.relation.related ? r.entry.relation.label : '관계 미정', style: const TextStyle(color: Pal.muted, fontSize: 13)), + ]), + ), + const Icon(Icons.chevron_right, color: Pal.muted), + ]), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/held_shares_screen.dart b/lib/modules/ui/src/screens/held_shares_screen.dart new file mode 100644 index 0000000..24f5aa2 --- /dev/null +++ b/lib/modules/ui/src/screens/held_shares_screen.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:friendly_map/modules/app/app.dart'; + +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; + +/// 친구가 나에게 맡긴 복구 조각. 그 친구가 새 폰에서 복구할 때 조각 글자를 알려 준다. +class HeldSharesScreen extends StatelessWidget { + const HeldSharesScreen({super.key}); + + Future _show(BuildContext context, HeldShare h) { + return showDialog( + context: context, + builder: (d) => AlertDialog( + title: Text('${h.ownerName}님의 복구 조각'), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('본인이 맞는지 직접 확인한 뒤에만 알려 주세요.', style: TextStyle(fontSize: 12, color: Pal.danger)), + const SizedBox(height: 10), + SelectableText(h.code, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), + ]), + actions: [ + TextButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: h.code)); + if (d.mounted) Navigator.pop(d); + if (context.mounted) showNote(context, '복사했어요.'); + }, + child: const Text('복사'), + ), + TextButton(onPressed: () => Navigator.pop(d), child: const Text('닫기')), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('내가 맡은 조각')), + body: AsyncView>( + load: (app) => app.recovery.held(), + builder: (context, list) { + if (list.isEmpty) return const EmptyNote('친구가 맡긴 복구 조각이 아직 없어요.'); + return ListView(padding: const EdgeInsets.only(top: 8), children: [ + for (final h in list) + ListTile( + leading: Avatar(h.ownerName, size: 40), + title: Text(h.ownerName), + subtitle: Text(h.ownerPhone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + trailing: const Icon(Icons.chevron_right, color: Pal.muted), + onTap: () => _show(context, h), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/home_edit_screen.dart b/lib/modules/ui/src/screens/home_edit_screen.dart new file mode 100644 index 0000000..5a13f67 --- /dev/null +++ b/lib/modules/ui/src/screens/home_edit_screen.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/layout/layout.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; + +/// 홈 편집: 구역을 끌어서 순서 바꾸기, 보이기/숨기기, 이름 바꾸기, 지우기, 처음으로. +class HomeEditScreen extends StatelessWidget { + const HomeEditScreen({super.key}); + + Future _rename(BuildContext context, HomeItem item) async { + final c = TextEditingController(text: item.title); + final name = await showDialog( + context: context, + builder: (d) => AlertDialog( + title: const Text('이름 바꾸기'), + content: TextField(controller: c, autofocus: true), + actions: [ + TextButton(onPressed: () => Navigator.pop(d), child: const Text('취소')), + TextButton(onPressed: () => Navigator.pop(d, c.text.trim()), child: const Text('저장')), + ], + ), + ); + c.dispose(); + if (name == null || name.isEmpty || !context.mounted) return; + await AppScope.of(context).app.layout.editHome(item.copyWith(title: name)); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return Scaffold( + appBar: AppBar( + title: const Text('홈 편집'), + actions: [ + TextButton( + onPressed: () async { + if (await askConfirm(context, '처음 구성으로 되돌릴까요?', '숨기거나 지운 구역이 모두 돌아와요.', ok: '되돌리기')) await app.layout.resetHome(); + }, + child: const Text('처음으로'), + ), + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('완료')), + ], + ), + body: AsyncView>( + load: (app) => app.layout.home(), + builder: (context, items) { + if (items.isEmpty) return const EmptyNote('구역이 모두 지워졌어요.\n"처음으로"를 누르면 돌아와요.'); + return ReorderableListView( + padding: const EdgeInsets.only(bottom: 32), + onReorderItem: (from, to) => app.layout.moveHome(from, to), + children: [ + for (final it in items) + ListTile( + key: ValueKey(it.id), + title: Text(it.title, style: TextStyle(color: it.visible ? Pal.text : Pal.muted)), + onTap: () => _rename(context, it), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + Switch(value: it.visible, onChanged: (v) => app.layout.editHome(it.copyWith(visible: v))), + IconButton(tooltip: '지우기', icon: const Icon(Icons.delete_outline, color: Pal.muted), onPressed: () => app.layout.removeHome(it.id)), + const SizedBox(width: 24), + ]), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/home_shell.dart b/lib/modules/ui/src/screens/home_shell.dart new file mode 100644 index 0000000..73afdc2 --- /dev/null +++ b/lib/modules/ui/src/screens/home_shell.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'card_wallet_screen.dart'; +import 'chat_list_screen.dart'; +import 'discover_screen.dart'; +import 'network_home_screen.dart'; +import 'privacy_screen.dart'; + +/// 아래 메뉴 5칸. 칸을 바꿔도 각 화면의 상태(스크롤 등)는 그대로 둔다. +class HomeShell extends StatefulWidget { + const HomeShell({super.key}); + + @override + State createState() => _HomeShellState(); +} + +class _HomeShellState extends State { + int _index = 0; + + static const _tabs = <(IconData, String)>[ + (Icons.home_outlined, '홈'), + (Icons.chat_bubble_outline, '채팅'), + (Icons.travel_explore, '찾기'), + (Icons.badge_outlined, '명함'), + (Icons.person_outline, '내 정보'), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack(index: _index, children: const [ + NetworkHomeScreen(), + ChatListScreen(), + DiscoverScreen(), + CardWalletScreen(), + PrivacyScreen(), + ]), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: [for (final t in _tabs) NavigationDestination(icon: Icon(t.$1), label: t.$2)], + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/infer_screen.dart b/lib/modules/ui/src/screens/infer_screen.dart new file mode 100644 index 0000000..32bd046 --- /dev/null +++ b/lib/modules/ui/src/screens/infer_screen.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/inference/inference.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; + +class _InferData { + const _InferData(this.person, this.guesses, this.links); + final Person? person; + final List guesses; + final List links; +} + +const _manualRelations = ['동창', '동문', '직장 동료', '전 직장 동료', '같은 모임', '같은 여행', '친척', '이웃', '기타']; + +/// 어디서 봤지? 주고받은 명함을 견주어 추측하고, 맞으면 연결 고리로 확정한다. 직접 적을 수도 있다. +class InferScreen extends StatefulWidget { + const InferScreen({super.key, required this.personId}); + + final String personId; + + @override + State createState() => _InferScreenState(); +} + +class _InferScreenState extends State { + final _detail = TextEditingController(); + String _relation = _manualRelations.first; + + @override + void dispose() { + _detail.dispose(); + super.dispose(); + } + + Future<_InferData> _load(FriendlyApp app) async => _InferData( + await app.friends.person(widget.personId), + await app.network.guessesFor(widget.personId), + await app.network.links(widget.personId), + ); + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return AsyncView<_InferData>( + load: _load, + builder: (context, d) { + final name = d.person?.name ?? '이 사람'; + return Scaffold( + appBar: AppBar(title: const Text('어디서 봤지?')), + body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 12), + child: Text('$name님과 주고받은 명함을 내 명함과 견주어 봤어요.', style: const TextStyle(color: Pal.muted, height: 1.5)), + ), + if (d.guesses.isEmpty) + const EmptyNote('겹치는 곳을 아직 찾지 못했어요.\n명함을 더 주고받거나 아래에서 직접 적어 보세요.'), + for (final g in d.guesses) + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(g.relation, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700)), + const SizedBox(width: 8), + Expanded(child: Text(g.detail, style: const TextStyle(color: Pal.muted), overflow: TextOverflow.ellipsis)), + Text('${g.confidence}%', style: const TextStyle(color: Pal.accent, fontWeight: FontWeight.w700)), + ]), + const SizedBox(height: 6), + Text(g.reason, style: const TextStyle(fontSize: 13, height: 1.4)), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonal( + onPressed: () async { + await app.network.addLink(widget.personId, g.relation, g.detail); + if (context.mounted) showNote(context, '연결 고리로 확정했어요.'); + }, + child: const Text('맞아요'), + ), + ), + ]), + ), + const SectionHeader('직접 적기'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap(spacing: 8, runSpacing: 8, children: [ + for (final r in _manualRelations) + ChoiceChip(label: Text(r), selected: _relation == r, onSelected: (_) => setState(() => _relation = r)), + ]), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: TextField(controller: _detail, decoration: const InputDecoration(labelText: '어디서? (예: 가람초 27회, 제주 여행)')), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: SizedBox( + height: 48, + width: double.infinity, + child: FilledButton( + onPressed: () async { + await app.network.addLink(widget.personId, _relation, _detail.text.trim()); + _detail.clear(); + if (context.mounted) showNote(context, '연결 고리를 남겼어요.'); + }, + child: const Text('연결 고리 남기기'), + ), + ), + ), + if (d.links.isNotEmpty) const SectionHeader('남겨 둔 연결 고리'), + for (final l in d.links) + Panel( + child: Row(children: [ + Expanded(child: Text(l.detail.isEmpty ? l.relation : '${l.relation} · ${l.detail}')), + IconButton( + tooltip: '지우기', + onPressed: () => app.network.removeLink(l.id), + icon: const Icon(Icons.close, color: Pal.muted), + ), + ]), + ), + ]), + ); + }, + ); + } +} diff --git a/lib/modules/ui/src/screens/location_screen.dart b/lib/modules/ui/src/screens/location_screen.dart new file mode 100644 index 0000000..5a7c5ba --- /dev/null +++ b/lib/modules/ui/src/screens/location_screen.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/visibility/visibility.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/radar_view.dart'; + +class _LocData { + const _LocData(this.on, this.mine, this.friends, this.recipients); + final bool on; + final GeoPoint? mine; + final List friends; + final Map recipients; +} + +/// 위치: 켜면 공개 범위 표에서 허용한 친구에게만 내 위치를 보내고, 친구들이 보내 준 위치를 레이더로 본다. +class LocationScreen extends StatefulWidget { + const LocationScreen({super.key}); + + @override + State createState() => _LocationScreenState(); +} + +class _LocationScreenState extends State { + bool _busy = false; + + Future<_LocData> _load(FriendlyApp app) async => + _LocData(await app.location.sharingOn(), await app.location.mine(), await app.location.friends(), await app.location.recipients()); + + Future _share() async { + final app = AppScope.of(context).app; + setState(() => _busy = true); + final n = await app.location.shareNow(); + if (!mounted) return; + setState(() => _busy = false); + showNote(context, n == 0 ? '보낼 수 있는 친구가 없거나 위치를 얻지 못했어요.' : '친구 $n명에게 위치를 보냈어요.'); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return Scaffold( + appBar: AppBar(title: const Text('위치')), + body: AsyncView<_LocData>( + load: _load, + builder: (context, d) { + final dots = [ + if (d.mine != null) + for (final f in d.friends) + RadarDot(f.person.name, bearingDegrees(d.mine!, f.point), distanceMeters(d.mine!, f.point)), + ]; + return ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ToggleRow( + title: '내 위치 공유', + subtitle: app.location.available ? '꺼져 있으면 아무에게도 보내지 않아요' : '이 기기에서는 위치를 쓸 수 없어요', + value: d.on, + onChanged: (v) => app.location.setSharing(app.location.available && v), + ), + if (d.on) ...[ + Text( + d.recipients.isEmpty + ? '받을 수 있는 친구가 없어요. 내 정보의 공개 범위 표에서 "실시간 위치"를 열어 주세요.' + : '받는 친구: ${d.recipients.entries.map((e) => '${e.key.name}${e.value == VLevel.partial ? '(대략)' : ''}').join(', ')}', + style: const TextStyle(fontSize: 12, color: Pal.muted, height: 1.5), + ), + const SizedBox(height: 8), + FilledButton.tonal(onPressed: _busy ? null : _share, child: const Text('지금 보내기')), + ], + ]), + ), + const SectionHeader('친구 위치'), + if (d.mine == null) const EmptyNote('내 위치를 얻지 못했어요. 위치 권한과 GPS 를 확인해 주세요.'), + Center(child: RadarView(dots: dots)), + if (d.friends.isEmpty) const EmptyNote('친구가 보내 준 위치가 아직 없어요.'), + for (final f in d.friends) + ListTile( + leading: Avatar(f.person.name, size: 40), + title: Text(f.person.name), + subtitle: Text( + d.mine == null ? '${f.point.lat.toStringAsFixed(3)}, ${f.point.lng.toStringAsFixed(3)}' : '${distanceLabel(distanceMeters(d.mine!, f.point))} · ${shortTime(f.at)}', + style: const TextStyle(fontSize: 12, color: Pal.muted), + ), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/network_home_screen.dart b/lib/modules/ui/src/screens/network_home_screen.dart new file mode 100644 index 0000000..9b1564a --- /dev/null +++ b/lib/modules/ui/src/screens/network_home_screen.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/layout/layout.dart'; + +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'card_wallet_screen.dart'; +import 'chat_list_screen.dart'; +import 'cluster_screen.dart'; +import 'discover_screen.dart'; +import 'family_screen.dart'; +import 'gathering_screen.dart'; +import 'home_edit_screen.dart'; +import 'location_screen.dart'; +import 'people_screen.dart'; + +/// 홈: 구역(템플릿)을 보여 준다. 길게 누르면 편집 모드. 구역 id → 화면 연결은 이 표 하나로만 한다. +class NetworkHomeScreen extends StatelessWidget { + const NetworkHomeScreen({super.key}); + + static final Map _targets = { + 'chats': () => const ChatListScreen(), + 'network': () => const PeopleScreen(), + 'family': () => const FamilyScreen(), + 'gathering': () => const GatheringScreen(), + 'company': () => const ClusterScreen(kind: CardKinds.company), + 'school': () => const ClusterScreen(kind: CardKinds.school), + 'group': () => const ClusterScreen(kind: CardKinds.group), + 'location': () => const LocationScreen(), + 'discover': () => const DiscoverScreen(), + 'cards': () => const CardWalletScreen(), + }; + + static Color _color(String id) => switch (id) { + 'company' => Pal.company, + 'school' => Pal.school, + 'group' => Pal.group, + 'family' || 'gathering' => Pal.accent, + _ => Pal.muted, + }; + + @override + Widget build(BuildContext context) { + void edit() => pushPage(context, const HomeEditScreen()); + return Scaffold( + appBar: AppBar( + title: const Text('Friendly Map', style: TextStyle(fontWeight: FontWeight.w700)), + actions: [IconButton(tooltip: '홈 편집', onPressed: edit, icon: const Icon(Icons.tune))], + ), + body: AsyncView>( + load: (app) => app.layout.home(), + builder: (context, all) { + final items = all.where((i) => i.visible && _targets.containsKey(i.id)).toList(); + if (items.isEmpty) return const EmptyNote('보이는 구역이 없어요.\n오른쪽 위 버튼에서 홈을 편집해 보세요.'); + return GridView.count( + padding: const EdgeInsets.all(16), + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.5, + children: [ + for (final it in items) + InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => pushPage(context, _targets[it.id]!()), + onLongPress: edit, + child: Ink( + decoration: BoxDecoration(color: Pal.surface, borderRadius: BorderRadius.circular(16), border: Border.all(color: Pal.line)), + padding: const EdgeInsets.all(14), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: _color(it.id), shape: BoxShape.circle)), + Text(it.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + ]), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/people_screen.dart b/lib/modules/ui/src/screens/people_screen.dart new file mode 100644 index 0000000..d79a5a2 --- /dev/null +++ b/lib/modules/ui/src/screens/people_screen.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'add_friend_screen.dart'; +import 'person_screen.dart'; + +/// 친구 전체 목록. +class PeopleScreen extends StatelessWidget { + const PeopleScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('인맥'), + actions: [IconButton(tooltip: '새 친구', onPressed: () => pushPage(context, const AddFriendScreen()), icon: const Icon(Icons.person_add_alt_1))], + ), + body: AsyncView>( + load: (app) => app.friends.people(), + builder: (context, list) { + if (list.isEmpty) return const EmptyNote('아직 친구가 없어요.\n오른쪽 위 버튼으로 첫 친구를 추가해 보세요.'); + return ListView(padding: const EdgeInsets.only(top: 8, bottom: 32), children: [ + for (final p in list) + ListTile( + leading: Avatar(p.name, size: 40), + title: Text(p.name), + subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + trailing: const Icon(Icons.chevron_right, color: Pal.muted), + onTap: () => pushPage(context, PersonScreen(personId: p.id)), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/person_facts_screen.dart b/lib/modules/ui/src/screens/person_facts_screen.dart new file mode 100644 index 0000000..9694aba --- /dev/null +++ b/lib/modules/ui/src/screens/person_facts_screen.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/kinship/kinship.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/person_picker.dart'; + +class _Data { + const _Data(this.person, this.facts, this.kin, this.inTree); + final Person? person; + final PersonFacts facts; + final KinPerson? kin; + final bool inTree; +} + +/// 한 사람의 정보 고치기: 생일, 돌아가신 날, 성별, 주제·메모, 가족 관계 잇기. +class PersonFactsScreen extends StatelessWidget { + const PersonFactsScreen({super.key, required this.personId}); + final String personId; + + Future<_Data> _load(FriendlyApp app) async { + final g = await app.kin.graph(); + return _Data(await app.friends.person(personId), await app.facts.get(personId), g.person(personId), g.has(personId)); + } + + @override + Widget build(BuildContext context) { + return AsyncView<_Data>( + load: _load, + builder: (context, d) => d.person == null + ? Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.')) + : _Form(person: d.person!, data: d), + ); + } +} + +class _Form extends StatefulWidget { + const _Form({required this.person, required this.data}); + final Person person; + final _Data data; + + @override + State<_Form> createState() => _FormState(); +} + +class _FormState extends State<_Form> { + late final _by = _c(widget.data.facts.birthYear); + late final _bm = _c(widget.data.facts.birthMonth); + late final _bd = _c(widget.data.facts.birthDay); + late final _dy = _c(widget.data.facts.deceasedYear); + late final _dm = _c(widget.data.facts.deceasedMonth); + late final _dd = _c(widget.data.facts.deceasedDay); + late final _topics = TextEditingController(text: widget.data.facts.topics.join(', ')); + late final _memo = TextEditingController(text: widget.data.facts.memo); + late bool _deceased = widget.data.facts.deceased; + late bool _birthLunar = widget.data.facts.birthLunar; + late bool _deceasedLunar = widget.data.facts.deceasedLunar; + late bool _deceasedLeap = widget.data.facts.deceasedLeap; + late Gender _gender = widget.data.kin?.gender ?? Gender.unknown; + + static TextEditingController _c(int? v) => TextEditingController(text: v?.toString() ?? ''); + static int? _n(TextEditingController c) => int.tryParse(c.text.trim()); + + @override + void dispose() { + for (final c in [_by, _bm, _bd, _dy, _dm, _dd, _topics, _memo]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + final app = AppScope.of(context).app; + final topics = _topics.text.split(RegExp(r'[,\n]')).map((t) => t.trim()).where((t) => t.isNotEmpty).toList(); + try { + await app.facts.save( + widget.person.id, + PersonFacts( + birthYear: _n(_by), + birthMonth: _n(_bm), + birthDay: _n(_bd), + deceasedYear: _deceased ? _n(_dy) : null, + deceasedMonth: _deceased ? _n(_dm) : null, + deceasedDay: _deceased ? _n(_dd) : null, + birthLunar: _birthLunar, + deceasedLunar: _deceased && _deceasedLunar, + deceasedLeap: _deceased && _deceasedLunar && _deceasedLeap, + topics: topics, + memo: _memo.text.trim(), + ), + ); + } on ArgumentError { + if (mounted) showNote(context, '날짜가 올바르지 않아요. 월과 일은 함께 적어 주세요.'); + return; + } + if (widget.data.inTree || _gender != Gender.unknown || _n(_by) != null) { + await app.kin.setProfile(widget.person.id, gender: _gender, birthYear: _n(_by)); + } + await app.events.reschedule(); + if (mounted) Navigator.of(context).pop(); + } + + /// "이 사람은 나의 ○○" 빠른 연결. + Future _quick(String role) async { + final app = AppScope.of(context).app; + final id = widget.person.id; + await app.kin.include(id, gender: _gender); + final ok = switch (role) { + 'parent' => await app.kin.setParent(KinGraph.meId, id), + 'child' => await app.kin.setParent(id, KinGraph.meId), + _ => await app.kin.setSpouse(KinGraph.meId, id), + }; + if (!mounted) return; + showNote(context, ok ? '가계도에 이어졌어요.' : '이어지지 않았어요. 부모가 이미 둘이거나 순서가 맞지 않아요.'); + } + + Future _with(String role) async { + final app = AppScope.of(context).app; + final people = (await app.friends.people()).where((p) => p.id != widget.person.id).toList(); + if (!mounted) return; + final other = await pickPerson(context, people, title: switch (role) { + 'parent' => '이 사람의 부모는?', + 'child' => '이 사람의 자녀는?', + _ => '이 사람의 배우자는?', + }); + if (other == null) return; + final id = widget.person.id; + final ok = switch (role) { + 'parent' => await app.kin.setParent(id, other.id), + 'child' => await app.kin.setParent(other.id, id), + _ => await app.kin.setSpouse(id, other.id), + }; + if (mounted) showNote(context, ok ? '이어졌어요.' : '이어지지 않았어요.'); + } + + Widget _dateRow(String label, TextEditingController y, TextEditingController m, TextEditingController d) => Row(children: [ + SizedBox(width: 56, child: Text(label)), + Expanded(flex: 3, child: _num(y, '연도')), + const SizedBox(width: 8), + Expanded(flex: 2, child: _num(m, '월')), + const SizedBox(width: 8), + Expanded(flex: 2, child: _num(d, '일')), + ]); + + Widget _num(TextEditingController c, String hint) => TextField( + controller: c, + keyboardType: TextInputType.number, + decoration: InputDecoration(hintText: hint, isDense: true), + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('${widget.person.name} 정보'), actions: [TextButton(onPressed: _save, child: const Text('저장'))]), + body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('기본', style: TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 10), + SegmentedButton( + segments: const [ + ButtonSegment(value: Gender.male, label: Text('남')), + ButtonSegment(value: Gender.female, label: Text('여')), + ButtonSegment(value: Gender.unknown, label: Text('모름')), + ], + selected: {_gender}, + onSelectionChanged: (s) => setState(() => _gender = s.first), + ), + const SizedBox(height: 10), + _dateRow('생일', _by, _bm, _bd), + ToggleRow(title: '생일이 음력', subtitle: '연도를 모르면 비워 두세요.', value: _birthLunar, onChanged: (v) => setState(() => _birthLunar = v)), + ToggleRow(title: '돌아가신 분', value: _deceased, onChanged: (v) => setState(() => _deceased = v)), + if (_deceased) _dateRow('기일', _dy, _dm, _dd), + if (_deceased) ToggleRow(title: '기일이 음력', subtitle: '제사는 보통 음력 날짜예요.', value: _deceasedLunar, onChanged: (v) => setState(() => _deceasedLunar = v)), + if (_deceased && _deceasedLunar) ToggleRow(title: '윤달', subtitle: '윤달이 든 해에만 돌아와요.', value: _deceasedLeap, onChanged: (v) => setState(() => _deceasedLeap = v)), + ]), + ), + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('통화 기억', style: TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + TextField(controller: _topics, decoration: const InputDecoration(labelText: '주로 어떤 일로 통화했나요? (쉼표로 나눠 적기)')), + TextField(controller: _memo, maxLines: 3, decoration: const InputDecoration(labelText: '메모 (통화하기 좋은 때 등)')), + ]), + ), + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('가족 관계 잇기', style: TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('성별을 먼저 고르면 호칭이 정확해져요.', style: TextStyle(fontSize: 11, color: Pal.muted)), + const SizedBox(height: 8), + const Text('나와의 사이', style: TextStyle(fontSize: 12, color: Pal.muted)), + Wrap(spacing: 8, children: [ + ActionChip(label: const Text('이 사람은 내 부모'), onPressed: () => _quick('parent')), + ActionChip(label: const Text('이 사람은 내 자녀'), onPressed: () => _quick('child')), + ActionChip(label: const Text('이 사람은 내 배우자'), onPressed: () => _quick('spouse')), + ]), + const SizedBox(height: 8), + const Text('다른 가족과의 사이', style: TextStyle(fontSize: 12, color: Pal.muted)), + Wrap(spacing: 8, children: [ + ActionChip(label: const Text('부모 고르기'), onPressed: () => _with('parent')), + ActionChip(label: const Text('자녀 고르기'), onPressed: () => _with('child')), + ActionChip(label: const Text('배우자 고르기'), onPressed: () => _with('spouse')), + ]), + if (widget.data.inTree) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: () async { + final ok = await askConfirm(context, '가계도에서 뺄까요?', '연락처는 그대로 두고 가족 관계 연결만 지워요.', ok: '빼기'); + if (ok && context.mounted) { + await AppScope.of(context).app.kin.exclude(widget.person.id); + if (context.mounted) Navigator.of(context).pop(); + } + }, + child: const Text('가계도에서 빼기', style: TextStyle(color: Pal.danger)), + ), + ], + ]), + ), + ]), + ); + } +} diff --git a/lib/modules/ui/src/screens/person_screen.dart b/lib/modules/ui/src/screens/person_screen.dart new file mode 100644 index 0000000..c032d65 --- /dev/null +++ b/lib/modules/ui/src/screens/person_screen.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/labels.dart'; +import '../widgets/person_summary.dart'; +import 'chat_room_screen.dart'; +import 'person_facts_screen.dart'; +import 'infer_screen.dart'; + +class _PersonData { + const _PersonData(this.person, this.cards, this.links, this.labels, this.info); + final Person? person; + final List cards; + final List links; + final Map labels; + final PersonInfo info; +} + +/// 사람 상세: 받은 명함들, 연결 고리, "어디서 봤지?"로 가는 길. +class PersonScreen extends StatelessWidget { + const PersonScreen({super.key, required this.personId}); + + final String personId; + + Future<_PersonData> _load(FriendlyApp app) async => _PersonData( + await app.friends.person(personId), + await app.network.receivedCards(personId), + await app.network.links(personId), + await fieldLabels(app), + await PersonInfo.load(app, personId), + ); + + Future _chat(BuildContext context, String kind) async { + final app = AppScope.of(context).app; + final room = await app.chats.openDirect(personId, kind); + if (!context.mounted) return; + if (room == null) { + showNote(context, '${CardKinds.label(kind)} 대화는 ${CardKinds.label(kind)} 명함을 서로 공유한 뒤에 할 수 있어요.'); + return; + } + pushPage(context, ChatRoomScreen(roomId: room.id)); + } + + Future _call(BuildContext context, bool sms) async { + final app = AppScope.of(context).app; + final ok = sms ? await app.calls.sms(personId) : await app.calls.call(personId); + if (!ok && context.mounted) showNote(context, '이 번호로는 ${sms ? '문자를' : '전화를'} 걸 수 없어요.'); + } + + @override + Widget build(BuildContext context) { + return AsyncView<_PersonData>( + load: _load, + builder: (context, d) { + final p = d.person; + if (p == null) return Scaffold(appBar: AppBar(), body: const EmptyNote('찾을 수 없는 사람이에요.')); + return Scaffold( + appBar: AppBar(title: Text(p.name), actions: [ + IconButton( + tooltip: '정보 고치기', + onPressed: () => pushPage(context, PersonFactsScreen(personId: p.id)), + icon: const Icon(Icons.edit_outlined), + ), + ]), + body: ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + if (d.info.markDeceased) DeceasedBanner(d.info.facts), + Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + Avatar(p.name, size: 56), + const SizedBox(width: 14), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(p.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + if (d.info.reachable) Text(p.phone, style: const TextStyle(color: Pal.muted)), + if (p.publicKey == null && d.info.reachable) const Text('아직 수락 전이에요', style: TextStyle(color: Pal.accent, fontSize: 12)), + ]), + ), + ]), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row(children: [ + Expanded( + child: FilledButton.icon( + onPressed: () => _chat(context, CardKinds.personal), + icon: const Icon(Icons.chat_bubble_outline), + label: const Text('대화'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: () => pushPage(context, InferScreen(personId: p.id)), + icon: const Icon(Icons.travel_explore), + label: const Text('어디서 봤지?'), + ), + ), + ]), + ), + if (d.info.reachable && d.info.canCall) + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: Row(children: [ + Expanded( + child: FilledButton.tonalIcon( + onPressed: () => _call(context, false), + icon: const Icon(Icons.call), + label: const Text('전화'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.tonalIcon( + onPressed: () => _call(context, true), + icon: const Icon(Icons.sms_outlined), + label: const Text('문자'), + ), + ), + ]), + ), + const SizedBox(height: 10), + RelationPanel(d.info.relations, onEdit: () => pushPage(context, PersonFactsScreen(personId: p.id))), + CallPanel(d.info), + EventsPanel(d.info.events), + if (d.links.isNotEmpty) ...[ + const SectionHeader('연결 고리'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap(spacing: 8, runSpacing: 8, children: [ + for (final l in d.links) + Chip(label: Text(l.detail.isEmpty ? l.relation : '${l.relation} · ${l.detail}')), + ]), + ), + ], + const SectionHeader('받은 명함'), + if (d.cards.isEmpty) const EmptyNote('받은 명함이 아직 없어요.'), + for (final c in d.cards) _ReceivedCardTile(c, d.labels), + const SectionHeader('이 사람과 대화하기'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap(spacing: 8, runSpacing: 8, children: [ + for (final k in CardKinds.all.where((k) => k != CardKinds.personal)) + ActionChip(label: Text('${CardKinds.label(k)} 대화'), onPressed: () => _chat(context, k)), + ]), + ), + ]), + ); + }, + ); + } +} + +class _ReceivedCardTile extends StatelessWidget { + const _ReceivedCardTile(this.c, this.labels); + + final ReceivedCard c; + final Map labels; + + @override + Widget build(BuildContext context) { + final lines = filledFields(decodeFields(c.fieldsJson), labels); + return Panel( + onLongPress: () async { + final ok = await askConfirm(context, '이 명함을 지울까요?', '내 기기에서만 지워져요. 상대의 명함은 그대로예요.', ok: '지우기'); + if (ok && context.mounted) await AppScope.of(context).app.network.deleteReceivedCard(c.id); + }, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + KindChip(c.kind, CardKinds.label(c.kind)), + const Spacer(), + Text(shortTime(c.updatedAt), style: const TextStyle(fontSize: 11, color: Pal.muted)), + ]), + const SizedBox(height: 8), + if (lines.isEmpty) const Text('비어 있는 명함', style: TextStyle(color: Pal.muted)), + for (final e in lines) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 72, child: Text(e.key, style: const TextStyle(fontSize: 12, color: Pal.muted))), + Expanded(child: Text(e.value)), + ]), + ), + ]), + ); + } +} diff --git a/lib/modules/ui/src/screens/privacy_screen.dart b/lib/modules/ui/src/screens/privacy_screen.dart new file mode 100644 index 0000000..9adb40d --- /dev/null +++ b/lib/modules/ui/src/screens/privacy_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/verify_phone_tile.dart'; +import '../widgets/visibility_matrix_view.dart'; +import 'discover_screen.dart'; +import 'settings_screen.dart'; + +class _MeData { + const _MeData(this.me, this.settings, this.hideAll, this.requests); + final MyProfile? me; + final AppSettings settings; + final bool hideAll; + final int requests; +} + +/// 내 정보: 프로필, 명함 교환 방식, 모든 정보 숨기기, 저장·보안 설정으로 가는 길. +class PrivacyScreen extends StatelessWidget { + const PrivacyScreen({super.key}); + + Future<_MeData> _load(FriendlyApp app) async => + _MeData(await app.profile.me(), await app.settings.load(), await app.settings.hideAll(), (await app.friends.requests()).length); + + Future _rename(BuildContext context, String current) async { + final app = AppScope.of(context).app; + final c = TextEditingController(text: current); + final name = await showDialog( + context: context, + builder: (d) => AlertDialog( + title: const Text('이름 바꾸기'), + content: TextField(controller: c, autofocus: true), + actions: [ + TextButton(onPressed: () => Navigator.pop(d), child: const Text('취소')), + TextButton(onPressed: () => Navigator.pop(d, c.text.trim()), child: const Text('저장')), + ], + ), + ); + c.dispose(); + if (name != null && name.isNotEmpty) await app.profile.rename(name); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return Scaffold( + appBar: AppBar( + title: const Text('내 정보', style: TextStyle(fontWeight: FontWeight.w700)), + actions: [TextButton(onPressed: () => pushPage(context, const SettingsScreen()), child: const Text('저장·보안'))], + ), + body: AsyncView<_MeData>( + load: _load, + builder: (context, d) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Panel( + onTap: d.me == null ? null : () => _rename(context, d.me!.name), + child: Row(children: [ + Avatar(d.me?.name ?? '?', size: 48), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(d.me?.name ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 2), + Text(d.me?.phone ?? '', style: const TextStyle(fontSize: 12, color: Pal.muted)), + ]), + ), + const Icon(Icons.edit_outlined, color: Pal.muted, size: 18), + ]), + ), + const VerifyPhoneTile(), + if (d.requests > 0) + Panel( + onTap: () => pushPage(context, const DiscoverScreen()), + child: Row(children: [ + const Icon(Icons.notifications_active_outlined, color: Pal.accent), + const SizedBox(width: 12), + Expanded(child: Text('받은 친구 요청 ${d.requests}건')), + const Icon(Icons.chevron_right, color: Pal.muted), + ]), + ), + const SectionHeader('공개 범위'), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text('칸을 눌러 공개 · 일부 · 숨김으로 바꿔요. 명함을 보낼 때 받는 사람의 관계에 맞게 적용돼요.', style: TextStyle(fontSize: 11, color: Pal.muted)), + ), + const VisibilityMatrixView(), + const SectionHeader('명함 교환 방식'), + RadioGroup( + groupValue: d.settings.exchangeMode, + onChanged: (v) { + if (v != null) app.settings.update(exchangeMode: v); + }, + child: const Column(children: [ + RadioListTile( + value: 'request', + activeColor: Pal.accent, + title: Text('요청 후 상대 수락'), + subtitle: Text('요청이 오면 내가 확인하고 받아요', style: TextStyle(fontSize: 11, color: Pal.muted)), + ), + RadioListTile( + value: 'auto', + activeColor: Pal.accent, + title: Text('자동 교환'), + subtitle: Text('요청이 오면 바로 친구가 되고 기본 명함을 보내요', style: TextStyle(fontSize: 11, color: Pal.muted)), + ), + ]), + ), + const SectionHeader('숨기기'), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ToggleRow( + title: '모든 정보 숨기기', + subtitle: '모르는 사람의 친구 요청을 받지 않아요', + value: d.hideAll, + onChanged: (v) => app.settings.setHideAll(v), + ), + ), + ]), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/profile_setup_screen.dart b/lib/modules/ui/src/screens/profile_setup_screen.dart new file mode 100644 index 0000000..dd0f409 --- /dev/null +++ b/lib/modules/ui/src/screens/profile_setup_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; + +/// 내 번호와 이름을 정한다 (기본 명함 5장이 만들어진다). +class ProfileSetupScreen extends StatefulWidget { + const ProfileSetupScreen({super.key, required this.onDone}); + + final VoidCallback onDone; + + @override + State createState() => _ProfileSetupScreenState(); +} + +class _ProfileSetupScreenState extends State { + final _phone = TextEditingController(); + final _name = TextEditingController(); + String? _error; + bool _busy = false; + + @override + void dispose() { + _phone.dispose(); + _name.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _busy = true; + _error = null; + }); + final p = await AppScope.of(context).app.profile.setUp(_phone.text, _name.text); + if (!mounted) return; + if (p == null) { + setState(() { + _busy = false; + _error = '전화번호와 이름을 확인해 주세요.'; + }); + return; + } + widget.onDone(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('내 정보')), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text('친구가 나를 찾을 때 쓰는 전화번호와 이름이에요.', style: TextStyle(color: Pal.muted, height: 1.5)), + const SizedBox(height: 20), + TextField( + controller: _phone, + keyboardType: TextInputType.phone, + decoration: const InputDecoration(labelText: '내 전화번호', hintText: '010-1234-5678'), + ), + const SizedBox(height: 14), + TextField( + controller: _name, + textInputAction: TextInputAction.done, + decoration: const InputDecoration(labelText: '이름'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text(_error!, style: const TextStyle(color: Pal.danger)), + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: FilledButton(onPressed: _busy ? null : _save, child: const Text('저장하고 시작')), + ), + ], + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/qr_screens.dart b/lib/modules/ui/src/screens/qr_screens.dart new file mode 100644 index 0000000..960191f --- /dev/null +++ b/lib/modules/ui/src/screens/qr_screens.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/discovery/discovery.dart'; +import 'package:qr_flutter/qr_flutter.dart' show QrImageView; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; + +/// 내 QR: 상대가 읽으면 내 번호·이름·공개 키가 전해져 바로 친구 요청이 간다. +class MyQrScreen extends StatelessWidget { + const MyQrScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('내 QR')), + body: AsyncView( + load: (app) => app.friends.myQr(), + builder: (context, q) { + if (q == null) return const EmptyNote('내 정보를 먼저 정해 주세요.'); + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + child: QrImageView(data: q.encode(), size: 240, backgroundColor: Colors.white), + ), + const SizedBox(height: 20), + Text(q.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(q.phone, style: const TextStyle(color: Pal.muted)), + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text('상대가 Friendly Map 으로 이 QR 을 읽으면 서로 명함을 교환할 수 있어요.', + textAlign: TextAlign.center, style: TextStyle(color: Pal.muted, height: 1.5)), + ), + ]), + ); + }, + ), + ); + } +} + +/// 상대의 QR 을 카메라로 읽어 친구를 추가한다. +class ScanQrScreen extends StatefulWidget { + const ScanQrScreen({super.key}); + + @override + State createState() => _ScanQrScreenState(); +} + +class _ScanQrScreenState extends State { + bool _handled = false; + + Future _onCode(String code) async { + if (_handled) return; + _handled = true; + final p = await AppScope.of(context).app.friends.addFromQr(code); + if (!mounted) return; + if (p == null) { + _handled = false; + showNote(context, 'Friendly Map 의 QR 이 아니에요.'); + return; + } + showNote(context, '${p.name}님에게 친구 요청을 보냈어요.'); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final factory = AppScope.of(context).qr; + return Scaffold( + appBar: AppBar(title: const Text('QR 읽기')), + body: factory == null + ? const EmptyNote('이 기기에서는 카메라를 쓸 수 없어요.') + : factory.build(onCode: _onCode), + ); + } +} diff --git a/lib/modules/ui/src/screens/recovery_entrust_screen.dart b/lib/modules/ui/src/screens/recovery_entrust_screen.dart new file mode 100644 index 0000000..fc14093 --- /dev/null +++ b/lib/modules/ui/src/screens/recovery_entrust_screen.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; + +class _Data { + const _Data(this.people, this.split, this.settings); + final List people; + final RecoverySplit? split; + final AppSettings settings; +} + +/// 복구 조각을 친구에게 맡긴다. 친구마다 조각 한 장, 설정한 수만큼의 친구에게 나눠 맡긴다. +class RecoveryEntrustScreen extends StatefulWidget { + const RecoveryEntrustScreen({super.key}); + + @override + State createState() => _RecoveryEntrustScreenState(); +} + +class _RecoveryEntrustScreenState extends State { + bool _busy = false; + + Future<_Data> _load(FriendlyApp app) async => + _Data(await app.friends.people(), await app.recovery.split(), await app.settings.load()); + + Future _resplit(AppSettings s) async { + final scope = AppScope.of(context); + setState(() => _busy = true); + try { + final codes = await scope.bootstrap.splitRecovery(s.recoveryShares, s.recoveryThreshold); + await scope.app.recovery.saveSplit(codes, s.recoveryThreshold); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _entrust(Person p, RecoverySplit split) async { + final app = AppScope.of(context).app; + final index = split.given[p.id] ?? split.free.first; + final r = await app.recovery.entrust(p.id, index); + if (!mounted) return; + showNote(context, switch (r.name) { + 'sent' => '${p.name}님에게 조각을 맡겼어요.', + 'queued' => '${p.name}님이 온라인이 되면 전달돼요.', + 'noKey' => '${p.name}님의 키를 아직 몰라요. 친구 수락이 끝난 뒤에 다시 해 주세요.', + _ => '맡기지 못했어요.', + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('복구 조각 맡기기')), + body: AsyncView<_Data>( + load: _load, + builder: (context, d) { + final split = d.split; + final stale = split == null || split.total != d.settings.recoveryShares || split.threshold != d.settings.recoveryThreshold; + return ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + split == null || stale + ? '${d.settings.recoveryShares}조각으로 나눠 친구에게 맡겨요' + : '맡긴 친구 ${split.entrusted} / ${split.total}명', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text('${d.settings.recoveryShares}명 중 ${d.settings.recoveryThreshold}명이 조각을 내놓으면 복구돼요. 친구 한 명의 조각만으로는 아무것도 알 수 없어요.', + style: const TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), + if (stale) ...[ + const SizedBox(height: 10), + if (split != null) + const Text('조각 수나 필요한 수를 바꿔서 새로 나눠야 해요. 이전에 맡긴 조각은 쓸 수 없게 돼요.', style: TextStyle(fontSize: 12, color: Pal.danger, height: 1.5)), + const SizedBox(height: 8), + FilledButton(onPressed: _busy ? null : () => _resplit(d.settings), child: Text(split == null ? '조각 만들기' : '새로 나누기')), + ], + ]), + ), + if (!stale && d.people.isEmpty) const EmptyNote('아직 친구가 없어요.'), + if (!stale) + for (final p in d.people) + ListTile( + leading: Avatar(p.name, size: 40), + title: Text(p.name), + subtitle: Text(split.given.containsKey(p.id) ? '맡김 · ${split.given[p.id]! + 1}번 조각' : p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + trailing: split.given.containsKey(p.id) + ? TextButton(onPressed: () => _entrust(p, split), child: const Text('다시 보내기')) + : FilledButton.tonal(onPressed: split.free.isEmpty || p.publicKey == null ? null : () => _entrust(p, split), child: const Text('맡기기')), + ), + ]); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/settings_screen.dart b/lib/modules/ui/src/screens/settings_screen.dart new file mode 100644 index 0000000..eb21656 --- /dev/null +++ b/lib/modules/ui/src/screens/settings_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import '../widgets/event_settings_panel.dart'; +import 'held_shares_screen.dart'; +import 'recovery_entrust_screen.dart'; +import 'welcome_screen.dart'; + +const int _mb = 1024 * 1024; + +/// 저장·보안 설정: 별도 저장 기준, 기기 저장 공간 기준, 복구 코드와 조각 수. +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + Future _showCode(BuildContext context) async { + final scope = AppScope.of(context); + final phrase = await scope.bootstrap.recoveryPhrase(); + if (!context.mounted) return; + if (phrase == null) { + showNote(context, '이 기기에서 복구 코드를 찾을 수 없어요.'); + return; + } + await Navigator.of(context).push(MaterialPageRoute( + builder: (c) => RecoveryCodeScreen(phrase: phrase, readOnly: true, onDone: () => Navigator.of(c).pop()), + )); + } + + @override + Widget build(BuildContext context) { + final svc = AppScope.of(context).app.settings; + return Scaffold( + appBar: AppBar(title: const Text('저장 · 보안 설정')), + body: AsyncView( + load: (app) => app.settings.load(), + builder: (context, s) => ListView(padding: const EdgeInsets.only(bottom: 32), children: [ + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('별도 저장 기준', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('지우지 않고 영구 보관하되, 아래 기준을 넘으면 별도 저장소로 나눠 담아요.', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), + _Stepper( + title: '대화 텍스트 크기', + hint: '대화방 하나가 이 크기를 넘으면', + value: '${(s.separateTextBytes / _mb).toStringAsFixed(s.separateTextBytes % _mb == 0 ? 0 : 1)} MB', + onMinus: () => svc.update(separateTextBytes: s.separateTextBytes > _mb ? s.separateTextBytes - _mb : s.separateTextBytes ~/ 2), + onPlus: () => svc.update(separateTextBytes: s.separateTextBytes >= _mb ? s.separateTextBytes + _mb : s.separateTextBytes * 2), + ), + Row(children: [ + const Expanded(child: Text('기기 저장 공간 사용률')), + Text('${s.storageUsagePercent}%', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Pal.accent)), + ]), + Slider( + min: 50, + max: 95, + divisions: 45, + value: s.storageUsagePercent.toDouble(), + onChanged: (v) => svc.update(storageUsagePercent: v.round()), + ), + ]), + ), + const EventSettingsPanel(), + Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('복구 코드', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('코드를 잃으면 데이터를 되살릴 방법이 없어요.', style: TextStyle(fontSize: 12, color: Pal.danger, height: 1.5)), + const SizedBox(height: 8), + SizedBox(width: double.infinity, height: 44, child: OutlinedButton(onPressed: () => _showCode(context), child: const Text('코드 다시 확인'))), + const SizedBox(height: 12), + const Text('조각 맡기기', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700)), + _Stepper( + title: '조각 수', + value: '${s.recoveryShares}개', + onMinus: () => svc.update(recoveryShares: s.recoveryShares - 1), + onPlus: () => svc.update(recoveryShares: s.recoveryShares + 1), + ), + _Stepper( + title: '복구에 필요한 수', + value: '${s.recoveryThreshold}개', + onMinus: () => svc.update(recoveryThreshold: s.recoveryThreshold - 1), + onPlus: () => svc.update(recoveryThreshold: s.recoveryThreshold + 1), + ), + Text('${s.recoveryShares}명 중 ${s.recoveryThreshold}명이 모이면 복구돼요.', style: const TextStyle(fontSize: 12, color: Pal.muted)), + const SizedBox(height: 10), + Row(children: [ + Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const RecoveryEntrustScreen()), child: const Text('친구에게 맡기기'))), + const SizedBox(width: 8), + Expanded(child: OutlinedButton(onPressed: () => pushPage(context, const HeldSharesScreen()), child: const Text('내가 맡은 조각'))), + ]), + ]), + ), + ]), + ), + ); + } +} + +class _Stepper extends StatelessWidget { + const _Stepper({required this.title, required this.value, required this.onMinus, required this.onPlus, this.hint}); + + final String title; + final String? hint; + final String value; + final VoidCallback onMinus; + final VoidCallback onPlus; + + @override + Widget build(BuildContext context) { + return Row(children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title), + if (hint != null) Text(hint!, style: const TextStyle(fontSize: 11, color: Pal.muted)), + ]), + ), + IconButton.outlined(tooltip: '줄이기', onPressed: onMinus, icon: const Icon(Icons.remove)), + SizedBox(width: 64, child: Text(value, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Pal.accent))), + IconButton.outlined(tooltip: '늘리기', onPressed: onPlus, icon: const Icon(Icons.add)), + ]); + } +} diff --git a/lib/modules/ui/src/screens/shake_pick_screen.dart b/lib/modules/ui/src/screens/shake_pick_screen.dart new file mode 100644 index 0000000..41b5102 --- /dev/null +++ b/lib/modules/ui/src/screens/shake_pick_screen.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/card_check_list.dart'; +import '../widgets/common.dart'; + +/// 흔든 뒤: 주변 사람 하나와 보낼 명함을 고른다. 처음 보는 사람이면 친구로 추가하며 보낸다. +class ShakePickScreen extends StatefulWidget { + const ShakePickScreen({super.key, required this.nearby}); + + final List nearby; + + @override + State createState() => _ShakePickScreenState(); +} + +class _ShakePickScreenState extends State { + final Set _cards = {}; + bool _seeded = false; + String? _phone; + bool _busy = false; + + Future _send() async { + final phone = _phone; + if (phone == null || _cards.isEmpty || _busy) return; + setState(() => _busy = true); + final app = AppScope.of(context).app; + final target = widget.nearby.firstWhere((n) => n.phone == phone); + final person = target.known ?? await app.friends.addNearby(phone); + final n = person == null ? 0 : await app.cards.shareWith(person.id, _cards.toList()); + if (!mounted) return; + setState(() => _busy = false); + showNote(context, person == null ? '친구로 추가하지 못했어요.' : '명함 $n장을 보냈어요.'); + if (person != null) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final ready = _phone != null && _cards.isNotEmpty && !_busy; + return Scaffold( + appBar: AppBar(title: const Text('흔든 뒤 명함 선택')), + body: AsyncView>( + load: (app) => app.cards.mine(), + builder: (context, cards) { + if (!_seeded) { + _seeded = true; + _cards.addAll(cards.where((c) => c.shareDefault).map((c) => c.id)); + } + return ListView(padding: const EdgeInsets.only(bottom: 96), children: [ + const SectionHeader('주변 사람'), + if (widget.nearby.isEmpty) const EmptyNote('주변에서 찾은 사람이 없어요. 같은 와이파이인지 확인해 주세요.'), + RadioGroup( + groupValue: _phone, + onChanged: (v) => setState(() => _phone = v), + child: Column(children: [ + for (final n in widget.nearby) + RadioListTile( + value: n.phone, + activeColor: Pal.accent, + title: Text(n.known?.name ?? n.phone), + subtitle: Text(n.known == null ? '처음 보는 사람 · 친구로 추가해요' : n.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + ), + ]), + ), + const SectionHeader('보낼 명함'), + CardCheckList( + cards: cards, + selected: _cards, + onToggle: (id, on) => setState(() => on ? _cards.add(id) : _cards.remove(id)), + ), + ]); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: ready ? _send : null, + backgroundColor: ready ? Pal.accent : Pal.line, + foregroundColor: Colors.black, + icon: const Icon(Icons.send), + label: Text(_cards.isEmpty ? '명함을 골라 주세요' : '${_cards.length}장 보내기'), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/shake_screen.dart b/lib/modules/ui/src/screens/shake_screen.dart new file mode 100644 index 0000000..fa9fad3 --- /dev/null +++ b/lib/modules/ui/src/screens/shake_screen.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/discovery/discovery.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import 'shake_pick_screen.dart'; + +/// 흔들기: 폰을 흔들면 주변 사람 목록과 보낼 명함 선택 화면으로 넘어간다. +/// 가속도 센서가 없는 환경에서는 "흔든 것으로 하기" 버튼으로 같은 흐름을 쓴다. +class ShakeScreen extends StatefulWidget { + const ShakeScreen({super.key}); + + @override + State createState() => _ShakeScreenState(); +} + +class _ShakeScreenState extends State { + final ShakeDetector _detector = ShakeDetector(); + final Stopwatch _clock = Stopwatch()..start(); + StreamSubscription? _sub; + bool _opening = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_sub != null) return; + _sub = AppScope.of(context).motion?.accelerationMagnitude.listen((m) { + if (_detector.feed(m, _clock.elapsedMilliseconds)) _open(); + }); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + Future _open() async { + if (_opening || !mounted) return; + _opening = true; + final nearby = await AppScope.of(context).app.friends.nearby(); + if (!mounted) return; + await Navigator.of(context).push(MaterialPageRoute(builder: (_) => ShakePickScreen(nearby: nearby))); + _opening = false; + } + + @override + Widget build(BuildContext context) { + final hasSensor = AppScope.of(context).motion != null; + return Scaffold( + appBar: AppBar(title: const Text('흔들기')), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.vibration, size: 96, color: Pal.accent), + const SizedBox(height: 20), + const Text('폰을 힘껏 흔들어 주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + const Text('같은 와이파이에 있는 Friendly Map 사용자에게 명함을 보낼 수 있어요.', + textAlign: TextAlign.center, style: TextStyle(color: Pal.muted, height: 1.5)), + if (!hasSensor) ...[ + const SizedBox(height: 24), + OutlinedButton(onPressed: _open, child: const Text('흔든 것으로 하기')), + ], + ]), + ), + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/share_screen.dart b/lib/modules/ui/src/screens/share_screen.dart new file mode 100644 index 0000000..0473145 --- /dev/null +++ b/lib/modules/ui/src/screens/share_screen.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/card_check_list.dart'; +import '../widgets/common.dart'; +import 'qr_screens.dart'; +import 'shake_screen.dart'; + +class _ShareData { + const _ShareData(this.people, this.cards); + final List people; + final List cards; +} + +/// 명함 공유: 친구를 고르고 보낼 명함을 체크한다. QR 과 흔들기로도 넘어갈 수 있다. +class ShareScreen extends StatefulWidget { + const ShareScreen({super.key, this.initialCardId}); + + final String? initialCardId; + + @override + State createState() => _ShareScreenState(); +} + +class _ShareScreenState extends State { + late final Set _cards = {if (widget.initialCardId != null) widget.initialCardId!}; + String? _personId; + bool _busy = false; + + Future<_ShareData> _load(FriendlyApp app) async => _ShareData(await app.friends.people(), await app.cards.mine()); + + Future _send() async { + final id = _personId; + if (id == null || _cards.isEmpty || _busy) return; + setState(() => _busy = true); + final app = AppScope.of(context).app; + final n = await app.cards.shareWith(id, _cards.toList()); + if (!mounted) return; + setState(() => _busy = false); + showNote(context, n == 0 ? '이미 모두 보낸 명함이에요.' : '명함 $n장을 보냈어요.'); + if (n > 0) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('명함 공유')), + body: AsyncView<_ShareData>( + load: _load, + builder: (context, d) => ListView(padding: const EdgeInsets.only(bottom: 96), children: [ + Panel( + child: Row(children: [ + Expanded(child: _Entry(icon: Icons.qr_code_2, label: '내 QR', onTap: () => pushPage(context, const MyQrScreen()))), + Expanded(child: _Entry(icon: Icons.vibration, label: '흔들기', onTap: () => pushPage(context, const ShakeScreen()))), + ]), + ), + const SectionHeader('받을 친구'), + if (d.people.isEmpty) const EmptyNote('아직 친구가 없어요. 새 친구를 먼저 추가해 주세요.'), + RadioGroup( + groupValue: _personId, + onChanged: (v) => setState(() => _personId = v), + child: Column(children: [ + for (final p in d.people) + RadioListTile(value: p.id, activeColor: Pal.accent, title: Text(p.name), subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted))), + ]), + ), + const SectionHeader('보낼 명함'), + CardCheckList( + cards: d.cards, + selected: _cards, + onToggle: (id, on) => setState(() => on ? _cards.add(id) : _cards.remove(id)), + ), + ]), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _personId != null && _cards.isNotEmpty && !_busy ? _send : null, + backgroundColor: _personId != null && _cards.isNotEmpty ? Pal.accent : Pal.line, + foregroundColor: Colors.black, + icon: const Icon(Icons.send), + label: Text(_cards.isEmpty ? '명함을 골라 주세요' : '${_cards.length}장 보내기'), + ), + ); + } +} + +class _Entry extends StatelessWidget { + const _Entry({required this.icon, required this.label, required this.onTap}); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column(children: [Icon(icon, color: Pal.accent, size: 28), const SizedBox(height: 6), Text(label)]), + ), + ); +} diff --git a/lib/modules/ui/src/screens/template_editor_screen.dart b/lib/modules/ui/src/screens/template_editor_screen.dart new file mode 100644 index 0000000..2cc5fc8 --- /dev/null +++ b/lib/modules/ui/src/screens/template_editor_screen.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/layout/layout.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; + +/// 명함 틀 만들기·고치기: 이름, 종류, 항목(추가·지우기·순서). +class TemplateEditorScreen extends StatelessWidget { + const TemplateEditorScreen({super.key, this.templateId}); + + final String? templateId; + + @override + Widget build(BuildContext context) { + return AsyncView( + load: (app) async => (await app.layout.templates()).where((t) => t.id == templateId).firstOrNull, + builder: (context, t) => _Editor(template: t), + ); + } +} + +class _Editor extends StatefulWidget { + const _Editor({required this.template}); + + final CardTemplate? template; + + @override + State<_Editor> createState() => _EditorState(); +} + +class _EditorState extends State<_Editor> { + late final TextEditingController _title = TextEditingController(text: widget.template?.title ?? ''); + late String _kind = widget.template?.kind ?? CardKinds.personal; + late final List _fields = List.of(widget.template?.fields ?? const [FieldDef(CardKeys.name, '이름')]); + + @override + void dispose() { + _title.dispose(); + super.dispose(); + } + + Future _save() async { + final title = _title.text.trim(); + if (title.isEmpty || _fields.isEmpty) { + showSnack('이름과 항목을 하나 이상 넣어 주세요.'); + return; + } + final nav = Navigator.of(context); + await AppScope.of(context).app.layout.saveTemplate( + CardTemplate(id: widget.template?.id ?? 'tpl-${newId()}', kind: _kind, title: title, fields: _fields), + ); + nav.pop(); + } + + void showSnack(String text) => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + + Future _addField() async { + final have = {for (final f in _fields) f.key}; + final custom = TextEditingController(); + final picked = await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (c) => Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, MediaQuery.of(c).viewInsets.bottom + 16), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('넣을 항목', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('아래 항목은 "어디서 봤지?"에 쓰여요.', style: TextStyle(fontSize: 12, color: Pal.muted)), + const SizedBox(height: 10), + Wrap(spacing: 8, runSpacing: 8, children: [ + for (final f in knownFieldDefs().where((f) => !have.contains(f.key))) + ActionChip(label: Text(f.label), onPressed: () => Navigator.pop(c, f)), + ]), + const SizedBox(height: 14), + TextField( + controller: custom, + decoration: InputDecoration( + labelText: '직접 만들기', + suffixIcon: IconButton( + tooltip: '추가', + icon: const Icon(Icons.add), + onPressed: () { + final label = custom.text.trim(); + if (label.isNotEmpty) Navigator.pop(c, FieldDef('x_${newId().substring(0, 8)}', label)); + }, + ), + ), + ), + ]), + ), + ); + custom.dispose(); + if (picked != null) setState(() => _fields.add(picked)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.template == null ? '새 명함 틀' : '명함 틀 고치기'), + actions: [TextButton(onPressed: _save, child: const Text('저장'))], + ), + body: ListView(padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ + TextField(controller: _title, decoration: const InputDecoration(labelText: '틀 이름 (예: 동호회 명함)')), + const SizedBox(height: 14), + Wrap(spacing: 8, children: [ + for (final k in CardKinds.all) + ChoiceChip(label: Text(CardKinds.label(k)), selected: _kind == k, onSelected: (_) => setState(() => _kind = k)), + ]), + const SizedBox(height: 10), + const Text('종류는 대화·공유 규칙에 쓰여요 (예: 회사 대화는 회사 명함이 필요).', style: TextStyle(fontSize: 11, color: Pal.muted)), + const SizedBox(height: 16), + Row(children: [ + const Expanded(child: Text('항목', style: TextStyle(fontWeight: FontWeight.w700))), + TextButton.icon(onPressed: _addField, icon: const Icon(Icons.add), label: const Text('항목 추가')), + ]), + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorderItem: (from, to) => setState(() { + final f = _fields.removeAt(from); + _fields.insert(to, f); + }), + children: [ + for (final f in _fields) + ListTile( + key: ValueKey(f.key), + contentPadding: EdgeInsets.zero, + title: Text(f.label), + subtitle: f.hint.isEmpty ? null : Text(f.hint, style: const TextStyle(fontSize: 11, color: Pal.muted)), + trailing: IconButton( + tooltip: '빼기', + icon: const Icon(Icons.close, color: Pal.muted), + onPressed: () => setState(() => _fields.remove(f)), + ), + ), + ], + ), + ]), + ); + } +} diff --git a/lib/modules/ui/src/screens/template_gallery_screen.dart b/lib/modules/ui/src/screens/template_gallery_screen.dart new file mode 100644 index 0000000..326bd22 --- /dev/null +++ b/lib/modules/ui/src/screens/template_gallery_screen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/layout/layout.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import '../widgets/async_view.dart'; +import '../widgets/common.dart'; +import 'card_edit_screen.dart'; +import 'template_editor_screen.dart'; + +/// 명함 틀 갤러리. 틀을 골라 새 명함을 만들고, 틀 자체도 고치거나 지우거나 순서를 바꿀 수 있다. +class TemplateGalleryScreen extends StatelessWidget { + const TemplateGalleryScreen({super.key}); + + Future _use(BuildContext context, CardTemplate t) async { + final card = await AppScope.of(context).app.cards.createFromTemplate(t.id); + if (card == null || !context.mounted) return; + await Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => CardEditScreen(cardId: card.id))); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return Scaffold( + appBar: AppBar( + title: const Text('어떤 명함을 만들까요?'), + actions: [ + IconButton(tooltip: '새 틀 만들기', onPressed: () => pushPage(context, const TemplateEditorScreen()), icon: const Icon(Icons.add_box_outlined)), + IconButton( + tooltip: '처음 틀로 되돌리기', + onPressed: () async { + if (await askConfirm(context, '처음 틀로 되돌릴까요?', '내가 고친 틀이 기본 틀로 바뀌어요. 이미 만든 명함은 그대로예요.', ok: '되돌리기')) { + await app.layout.resetTemplates(); + } + }, + icon: const Icon(Icons.restart_alt), + ), + ], + ), + body: AsyncView>( + load: (app) => app.layout.templates(), + builder: (context, list) { + if (list.isEmpty) return const EmptyNote('틀이 없어요. 오른쪽 위 버튼으로 새 틀을 만들어 보세요.'); + return ReorderableListView( + padding: const EdgeInsets.only(top: 8, bottom: 32), + onReorderItem: (from, to) => app.layout.moveTemplate(from, to), + children: [ + for (final t in list) + Panel( + key: ValueKey(t.id), + onTap: () => _use(context, t), + child: Row(children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + KindChip(t.kind, CardKinds.label(t.kind)), + const SizedBox(width: 8), + Flexible(child: Text(t.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), overflow: TextOverflow.ellipsis)), + ]), + const SizedBox(height: 6), + Text(t.fields.map((f) => f.label).join(' · '), style: const TextStyle(fontSize: 12, color: Pal.muted), maxLines: 2, overflow: TextOverflow.ellipsis), + ]), + ), + PopupMenuButton( + tooltip: '더 보기', + onSelected: (v) async { + if (v == 'edit') pushPage(context, TemplateEditorScreen(templateId: t.id)); + if (v == 'delete' && await askConfirm(context, '이 틀을 지울까요?', '이미 만든 명함은 그대로예요.', ok: '지우기')) { + await app.layout.removeTemplate(t.id); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('틀 고치기')), + PopupMenuItem(value: 'delete', child: Text('지우기')), + ], + ), + ]), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/modules/ui/src/screens/welcome_screen.dart b/lib/modules/ui/src/screens/welcome_screen.dart new file mode 100644 index 0000000..9e6651d --- /dev/null +++ b/lib/modules/ui/src/screens/welcome_screen.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 처음 화면: 새로 시작하거나 12단어 복구 코드로 되살린다. +class WelcomeScreen extends StatefulWidget { + const WelcomeScreen({super.key, required this.onCreate, required this.onRestore}); + + final Future Function() onCreate; + final Future Function(String phrase) onRestore; + + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State { + final _phrase = TextEditingController(); + bool _restoring = false; + bool _busy = false; + String? _error; + + @override + void dispose() { + _phrase.dispose(); + super.dispose(); + } + + Future _create() async { + setState(() => _busy = true); + await widget.onCreate(); + if (mounted) setState(() => _busy = false); + } + + Future _restore() async { + setState(() { + _busy = true; + _error = null; + }); + final ok = await widget.onRestore(_phrase.text); + if (!mounted) return; + setState(() { + _busy = false; + if (!ok) _error = '복구 코드가 올바르지 않아요. 단어 12개를 순서대로 적거나, 친구 조각을 필요한 수만큼 붙여 넣어 주세요.'; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + const SizedBox(height: 48), + const Text('Friendly Map', style: TextStyle(fontSize: 30, fontWeight: FontWeight.w800, color: Pal.accent)), + const SizedBox(height: 12), + const Text( + '내 인맥을 전방위적으로 확인하는 앱\n가족, 친구, 직장, 학교를 한 지도에서.', + style: TextStyle(fontSize: 15, height: 1.6, color: Pal.muted), + ), + const SizedBox(height: 12), + const Text( + '모든 정보는 이 기기 안에 암호화되어 저장되고, 공유한 것만 상대에게 갑니다.', + style: TextStyle(fontSize: 13, height: 1.6, color: Pal.muted), + ), + const SizedBox(height: 40), + SizedBox( + height: 52, + child: FilledButton( + onPressed: _busy ? null : _create, + child: const Text('새로 시작하기', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 52, + child: OutlinedButton( + onPressed: _busy ? null : () => setState(() => _restoring = !_restoring), + child: const Text('복구 코드로 되살리기'), + ), + ), + if (_restoring) ...[ + const SizedBox(height: 16), + TextField( + controller: _phrase, + minLines: 3, + maxLines: 4, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration(labelText: '복구 코드 (단어 12개) 또는 친구가 준 조각', hintText: 'word1 word2 word3 ... / fmr1.… 한 줄에 하나'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(color: Pal.danger, fontSize: 13)), + ), + const SizedBox(height: 12), + SizedBox( + height: 48, + child: FilledButton.tonal(onPressed: _busy ? null : _restore, child: const Text('되살리기')), + ), + ], + ], + ), + ), + ); + } +} + +/// 새로 만든 복구 코드(단어 12개)를 보여 준다. 적어 두었다고 확인해야 넘어간다. +class RecoveryCodeScreen extends StatefulWidget { + const RecoveryCodeScreen({super.key, required this.phrase, required this.onDone, this.readOnly = false}); + + final String phrase; + final VoidCallback onDone; + + /// 설정에서 다시 볼 때는 확인 없이 닫기만 한다. + final bool readOnly; + + @override + State createState() => _RecoveryCodeScreenState(); +} + +class _RecoveryCodeScreenState extends State { + bool _noted = false; + + @override + Widget build(BuildContext context) { + final words = widget.phrase.split(' '); + return Scaffold( + appBar: AppBar(title: const Text('복구 코드')), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + const Text( + '이 단어 12개가 내 모든 정보의 열쇠예요.\n기기를 잃어버리면 이 코드로만 되살릴 수 있고, 코드를 잃으면 방법이 없어요.', + style: TextStyle(color: Pal.danger, height: 1.6), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < words.length; i++) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Pal.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Pal.line), + ), + child: Text('${i + 1}. ${words[i]}', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + ], + ), + const SizedBox(height: 20), + const Text('종이에 적어 안전한 곳에 두세요. 화면 캡처나 메신저로 보내지 마세요.', + style: TextStyle(color: Pal.muted, fontSize: 13, height: 1.5)), + const SizedBox(height: 12), + if (!widget.readOnly) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _noted, + onChanged: (v) => setState(() => _noted = v ?? false), + title: const Text('적어 두었어요'), + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 8), + SizedBox( + height: 52, + child: FilledButton( + onPressed: (widget.readOnly || _noted) ? widget.onDone : null, + child: Text(widget.readOnly ? '닫기' : '시작하기'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/ui/src/startup_gate.dart b/lib/modules/ui/src/startup_gate.dart new file mode 100644 index 0000000..34ac747 --- /dev/null +++ b/lib/modules/ui/src/startup_gate.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; + +import 'bootstrap.dart'; +import 'screens/home_shell.dart'; +import 'screens/profile_setup_screen.dart'; +import 'screens/welcome_screen.dart'; + +/// 앱이 열리기까지의 순서: 키 확인 → (처음이면) 새로 시작/복구 → 앱 열기 → 내 정보 정하기 → 홈. +/// 앱 내부가 열리면 [onOpened] 로 알려서 위쪽이 AppScope 를 씌우게 한다. +class StartupGate extends StatefulWidget { + const StartupGate({super.key, required this.bootstrap, required this.onOpened}); + + final Bootstrap bootstrap; + final void Function(FriendlyApp app) onOpened; + + @override + State createState() => _StartupGateState(); +} + +enum _Stage { checking, welcome, code, profile, home } + +class _StartupGateState extends State { + _Stage _stage = _Stage.checking; + String _phrase = ''; + FriendlyApp? _app; + + @override + void initState() { + super.initState(); + _begin(); + } + + Future _begin() async { + if (await widget.bootstrap.hasKeys()) { + await _open(); + } else if (mounted) { + setState(() => _stage = _Stage.welcome); + } + } + + Future _open() async { + final app = await widget.bootstrap.openApp(); + if (!mounted) return; + _app = app; + widget.onOpened(app); + final ready = await app.profile.isSetUp(); + if (!mounted) return; + if (ready) await app.start(); + if (mounted) setState(() => _stage = ready ? _Stage.home : _Stage.profile); + } + + Future _profileDone() async { + await _app?.start(); + if (mounted) setState(() => _stage = _Stage.home); + } + + @override + Widget build(BuildContext context) { + switch (_stage) { + case _Stage.checking: + return const Scaffold(body: Center(child: CircularProgressIndicator())); + case _Stage.welcome: + return WelcomeScreen( + onCreate: () async { + final phrase = await widget.bootstrap.createKeys(); + if (mounted) { + setState(() { + _phrase = phrase; + _stage = _Stage.code; + }); + } + }, + onRestore: (phrase) async { + if (!await widget.bootstrap.restoreKeys(phrase)) return false; + await _open(); + return true; + }, + ); + case _Stage.code: + return RecoveryCodeScreen(phrase: _phrase, onDone: _open); + case _Stage.profile: + return ProfileSetupScreen(onDone: _profileDone); + case _Stage.home: + return const HomeShell(); + } + } +} diff --git a/lib/modules/ui/src/theme.dart b/lib/modules/ui/src/theme.dart new file mode 100644 index 0000000..5ecc436 --- /dev/null +++ b/lib/modules/ui/src/theme.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +/// 색 모음 (어두운 화면). 종류별 색은 명함·묶음 표시에 쓴다. +class Pal { + static const bg = Color(0xFF121417); + static const surface = Color(0xFF1B1E23); + static const line = Color(0xFF2E333B); + static const text = Color(0xFFECEEF1); + static const muted = Color(0xFFA3AAB5); + static const accent = Color(0xFFF5B83D); + static const company = Color(0xFF8AB4F8); + static const school = Color(0xFF5CC8BA); + static const group = Color(0xFFF28B82); + static const danger = Color(0xFFF28B82); + + static Color ofKind(String kind) => switch (kind) { + 'company' => company, + 'school' => school, + 'group' => group, + 'family' => accent, + _ => muted, + }; +} + +ThemeData buildTheme() { + final base = ColorScheme.fromSeed( + seedColor: Pal.accent, + brightness: Brightness.dark, + ).copyWith( + surface: Pal.bg, + primary: Pal.accent, + onPrimary: Colors.black, + onSurface: Pal.text, + outline: Pal.line, + ); + return ThemeData( + useMaterial3: true, + colorScheme: base, + scaffoldBackgroundColor: Pal.bg, + appBarTheme: const AppBarTheme( + backgroundColor: Pal.bg, + foregroundColor: Pal.text, + elevation: 0, + centerTitle: false, + ), + navigationBarTheme: const NavigationBarThemeData( + backgroundColor: Pal.surface, + indicatorColor: Color(0x33F5B83D), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Pal.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Pal.line), + ), + ), + dividerColor: Pal.line, + ); +} diff --git a/lib/modules/ui/src/widgets/async_view.dart b/lib/modules/ui/src/widgets/async_view.dart new file mode 100644 index 0000000..f875504 --- /dev/null +++ b/lib/modules/ui/src/widgets/async_view.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; + +import '../app_scope.dart'; + +/// 읽어 온 값으로 화면을 그리고, 앱 내부에 변경이 생기면 다시 읽는다. +/// 다시 읽는 동안에는 이전 화면을 그대로 둬서 깜빡이지 않는다. +class AsyncView extends StatefulWidget { + const AsyncView({super.key, required this.load, required this.builder}); + + final Future Function(FriendlyApp app) load; + final Widget Function(BuildContext context, T data) builder; + + @override + State> createState() => _AsyncViewState(); +} + +class _AsyncViewState extends State> { + T? _data; + bool _has = false; + Object? _error; + int _gen = 0; + StreamSubscription? _sub; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_sub == null) { + _sub = AppScope.of(context).app.changes.listen((_) => _reload()); + _reload(); + } + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + Future _reload() async { + final app = AppScope.of(context).app; + final g = ++_gen; + try { + final d = await widget.load(app); + if (!mounted || g != _gen) return; + setState(() { + _data = d; + _has = true; + _error = null; + }); + } catch (e) { + if (!mounted || g != _gen) return; + setState(() => _error = e); + } + } + + @override + Widget build(BuildContext context) { + if (_error != null) { + return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text('불러오지 못했어요\n$_error', textAlign: TextAlign.center))); + } + if (!_has) return const Center(child: CircularProgressIndicator()); + return widget.builder(context, _data as T); + } +} diff --git a/lib/modules/ui/src/widgets/card_check_list.dart b/lib/modules/ui/src/widgets/card_check_list.dart new file mode 100644 index 0000000..e06342b --- /dev/null +++ b/lib/modules/ui/src/widgets/card_check_list.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../theme.dart'; +import 'common.dart'; + +/// 내 명함을 체크해서 고르는 목록 (공유 화면·흔든 뒤 화면이 같이 쓴다). +class CardCheckList extends StatelessWidget { + const CardCheckList({super.key, required this.cards, required this.selected, required this.onToggle}); + + final List cards; + final Set selected; + final void Function(String cardId, bool on) onToggle; + + @override + Widget build(BuildContext context) { + if (cards.isEmpty) return const EmptyNote('보낼 명함이 없어요. 명함 탭에서 먼저 만들어 주세요.'); + return Column(children: [ + for (final c in cards) + CheckboxListTile( + value: selected.contains(c.id), + onChanged: (v) => onToggle(c.id, v ?? false), + activeColor: Pal.accent, + controlAffinity: ListTileControlAffinity.leading, + title: Text(c.title), + subtitle: Text(CardKinds.label(c.kind), style: const TextStyle(fontSize: 12, color: Pal.muted)), + ), + ]); + } +} diff --git a/lib/modules/ui/src/widgets/common.dart b/lib/modules/ui/src/widgets/common.dart new file mode 100644 index 0000000..bbee6e1 --- /dev/null +++ b/lib/modules/ui/src/widgets/common.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 둥근 상자 (목록 한 칸, 설명 묶음). +class Panel extends StatelessWidget { + const Panel({super.key, required this.child, this.onTap, this.onLongPress, this.padding = const EdgeInsets.all(14), this.margin = const EdgeInsets.fromLTRB(16, 0, 16, 10)}); + + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final EdgeInsets padding; + final EdgeInsets margin; + + @override + Widget build(BuildContext context) { + return Padding( + padding: margin, + child: Material( + color: Pal.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Pal.line), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + onLongPress: onLongPress, + child: Padding(padding: padding, child: child), + ), + ), + ); + } +} + +class SectionHeader extends StatelessWidget { + const SectionHeader(this.title, {super.key, this.trailing}); + + final String title; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 12, 8), + child: Row(children: [ + Expanded(child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700))), + if (trailing != null) trailing!, + ]), + ); + } +} + +/// 이름의 첫 글자를 담은 동그라미. +class Avatar extends StatelessWidget { + const Avatar(this.name, {super.key, this.size = 44, this.color = Pal.accent}); + + final String name; + final double size; + final Color color; + + @override + Widget build(BuildContext context) { + final letter = name.trim().isEmpty ? '?' : name.trim().characters.first; + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color.withValues(alpha: 0.18), + border: Border.all(color: color, width: 2), + ), + child: Text(letter, style: TextStyle(fontSize: size * 0.4, fontWeight: FontWeight.w700)), + ); + } +} + +/// 종류 이름표 (개인·회사 …). +class KindChip extends StatelessWidget { + const KindChip(this.kind, this.label, {super.key}); + + final String kind; + final String label; + + @override + Widget build(BuildContext context) { + final c = Pal.ofKind(kind); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(10), + ), + child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: c)), + ); + } +} + +/// 비어 있을 때 보여 주는 안내. +class EmptyNote extends StatelessWidget { + const EmptyNote(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32), + child: Center(child: Text(text, textAlign: TextAlign.center, style: const TextStyle(color: Pal.muted, height: 1.5))), + ); + } +} + +/// 이름 + 설명 + 켜고 끄는 스위치 한 줄 (44dp 이상). +class ToggleRow extends StatelessWidget { + const ToggleRow({super.key, required this.title, this.subtitle, required this.value, required this.onChanged}); + + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(title, style: const TextStyle(fontSize: 14)), + subtitle: subtitle == null ? null : Text(subtitle!, style: const TextStyle(fontSize: 11, color: Pal.muted)), + value: value, + onChanged: onChanged, + ); + } +} + +void showNote(BuildContext context, String text) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(text))); +} + +Future askConfirm(BuildContext context, String title, String body, {String ok = '확인'}) async { + final r = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('취소')), + TextButton(onPressed: () => Navigator.pop(c, true), child: Text(ok)), + ], + ), + ); + return r ?? false; +} + +Future pushPage(BuildContext context, Widget page) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); + +/// 시각(1/100초)을 "월/일 시:분" 으로. +String shortTime(int stamp) { + final d = DateTime.fromMillisecondsSinceEpoch(stamp * 10); + String two(int n) => n.toString().padLeft(2, '0'); + return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}'; +} diff --git a/lib/modules/ui/src/widgets/event_settings_panel.dart b/lib/modules/ui/src/widgets/event_settings_panel.dart new file mode 100644 index 0000000..4702b9a --- /dev/null +++ b/lib/modules/ui/src/widgets/event_settings_panel.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/events/events.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import 'async_view.dart'; +import 'common.dart'; + +class _Data { + const _Data(this.lead, this.showDeceased, this.canNotify); + final List lead; + final bool showDeceased; + final bool canNotify; +} + +/// 설정: 생일·기일을 며칠 전에 알릴지, 돌아가신 분 표시를 보일지. +class EventSettingsPanel extends StatefulWidget { + const EventSettingsPanel({super.key}); + + @override + State createState() => _EventSettingsPanelState(); +} + +class _EventSettingsPanelState extends State { + final _ctl = TextEditingController(); + bool _filled = false; + + @override + void dispose() { + _ctl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + return AsyncView<_Data>( + load: (a) async => _Data(await a.events.leadDays(), await a.events.showDeceased(), a.events.canNotify), + builder: (context, d) { + if (!_filled) { + _ctl.text = leadDaysText(d.lead); + _filled = true; + } + return Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('생일·기일 알림', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + const Text('며칠 전에 알릴지 숫자로 적어요. 0은 당일, 쉼표로 여러 개 (예: 7, 2, 1, 0)', style: TextStyle(fontSize: 12, color: Pal.muted, height: 1.5)), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: TextField(controller: _ctl, keyboardType: TextInputType.text, decoration: const InputDecoration(hintText: '1, 2', isDense: true))), + const SizedBox(width: 8), + FilledButton( + onPressed: () async { + await app.events.setLeadDays(_ctl.text); + if (!context.mounted) return; + final now = await app.events.leadDays(); + _ctl.text = leadDaysText(now); + if (context.mounted) showNote(context, d.canNotify ? '알림 날짜를 저장했어요: ${leadDaysText(now)}' : '저장했어요. (이 기기에서는 알림을 띄울 수 없어요)'); + }, + child: const Text('저장'), + ), + ]), + ToggleRow( + title: '돌아가신 분 표시', + subtitle: '켜면 사람 화면 맨 위와 목록에 故 표시가 보여요.', + value: d.showDeceased, + onChanged: (v) => app.events.setShowDeceased(v), + ), + ]), + ); + }, + ); + } +} diff --git a/lib/modules/ui/src/widgets/labels.dart b/lib/modules/ui/src/widgets/labels.dart new file mode 100644 index 0000000..d504a1b --- /dev/null +++ b/lib/modules/ui/src/widgets/labels.dart @@ -0,0 +1,18 @@ +import 'package:friendly_map/modules/app/app.dart'; + +/// 명함 항목 이름(key) → 사용자에게 보이는 이름. 명함 틀에서 가져온다. +Future> fieldLabels(FriendlyApp app) async { + final out = {}; + for (final t in await app.layout.templates()) { + for (final f in t.fields) { + out.putIfAbsent(f.key, () => f.label); + } + } + return out; +} + +/// 값이 있는 항목만 "이름: 값" 줄로. +List> filledFields(Map fields, Map labels) => [ + for (final e in fields.entries) + if (e.value.trim().isNotEmpty && e.key != 'former') MapEntry(labels[e.key] ?? e.key, e.value), + ]; diff --git a/lib/modules/ui/src/widgets/person_picker.dart b/lib/modules/ui/src/widgets/person_picker.dart new file mode 100644 index 0000000..15886ad --- /dev/null +++ b/lib/modules/ui/src/widgets/person_picker.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +import '../theme.dart'; +import 'common.dart'; + +/// 친구 중 한 명을 고르는 목록 (탭하면 그 사람을 돌려주며 닫힌다). +Future pickPerson(BuildContext context, List people, {String title = '누구에게?'}) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (c) => SafeArea( + child: SizedBox( + height: MediaQuery.of(c).size.height * 0.55, + child: Column(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Align(alignment: Alignment.centerLeft, child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700))), + ), + Expanded( + child: people.isEmpty + ? const EmptyNote('아직 친구가 없어요.') + : ListView(children: [ + for (final p in people) + ListTile( + minTileHeight: 56, + leading: Avatar(p.name, size: 40), + title: Text(p.name), + subtitle: Text(p.phone, style: const TextStyle(fontSize: 12, color: Pal.muted)), + onTap: () => Navigator.pop(c, p), + ), + ]), + ), + ]), + ), + ), + ); +} diff --git a/lib/modules/ui/src/widgets/person_summary.dart b/lib/modules/ui/src/widgets/person_summary.dart new file mode 100644 index 0000000..af2deb3 --- /dev/null +++ b/lib/modules/ui/src/widgets/person_summary.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/events/events.dart'; + +import '../theme.dart'; +import 'common.dart'; + +/// 사람 한 명에 대한 요약 재료: 관계, 사실(생일·기일·메모), 통화, 일정. +class PersonInfo { + const PersonInfo({ + required this.facts, + required this.relations, + required this.calls, + required this.events, + required this.showDeceased, + required this.reachable, + required this.canCall, + required this.canReadLog, + }); + + final PersonFacts facts; + final List relations; + final CallSummary calls; + final List events; + final bool showDeceased; + final bool reachable; + final bool canCall; + final bool canReadLog; + + bool get markDeceased => facts.deceased && showDeceased; + + static Future load(FriendlyApp app, String personId) async => PersonInfo( + facts: await app.facts.get(personId), + relations: await app.kin.explain(personId), + calls: await app.calls.summary(personId), + events: await app.events.forPerson(personId), + showDeceased: await app.events.showDeceased(), + reachable: await app.calls.reachable(personId), + canCall: app.calls.canCall, + canReadLog: app.calls.canReadLog, + ); +} + +/// 맨 위에 붙는 "돌아가신 분" 표시. +class DeceasedBanner extends StatelessWidget { + const DeceasedBanner(this.facts, {super.key}); + final PersonFacts facts; + + @override + Widget build(BuildContext context) { + final when = facts.deceasedText; + return Container( + width: double.infinity, + color: Pal.line, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Text('故', style: TextStyle(fontWeight: FontWeight.w800)), + const SizedBox(width: 8), + Text(when.isEmpty ? '돌아가신 분' : '돌아가신 분 · $when', style: const TextStyle(fontSize: 13)), + ]), + ); + } +} + +/// "나에게는 큰아버지 (3촌) / 철수에게는 당숙 (5촌)". +class RelationPanel extends StatelessWidget { + const RelationPanel(this.views, {super.key, required this.onEdit}); + final List views; + final VoidCallback onEdit; + + @override + Widget build(BuildContext context) { + final known = views.where((v) => v.relation.related).toList(); + return Panel( + onTap: onEdit, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('가족 관계', style: TextStyle(fontSize: 13, color: Pal.muted)), + const SizedBox(height: 6), + if (known.isEmpty) + const Text('아직 가계도에 없어요. 눌러서 관계를 정해 주세요.', style: TextStyle(color: Pal.muted)) + else + for (final v in known) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text(v.whose == '나' ? '나에게는 ${v.relation.label}' : '${v.whose}에게는 ${v.relation.label}', + style: TextStyle(fontSize: v.whose == '나' ? 17 : 14, fontWeight: v.whose == '나' ? FontWeight.w700 : FontWeight.w400)), + ), + ]), + ); + } +} + +/// 통화 요약 + 통화 가능 시간대 + 주제. +class CallPanel extends StatelessWidget { + const CallPanel(this.info, {super.key}); + final PersonInfo info; + + @override + Widget build(BuildContext context) { + final c = info.calls; + final f = info.facts; + final lines = []; + if (!info.reachable) { + lines.add(const Text('전화번호가 없는 가족이에요.', style: TextStyle(color: Pal.muted))); + } else if (!info.canReadLog) { + lines.add(const Text('이 기기에서는 통화 기록을 읽을 수 없어요.', style: TextStyle(color: Pal.muted))); + } else if (c.total == 0) { + lines.add(const Text('통화 기록이 없어요. (권한을 허용하지 않았을 수도 있어요)', style: TextStyle(color: Pal.muted))); + } else { + final last = c.lastAt!; + final dir = switch (c.lastDirection!) { + CallDirection.incoming => '받음', + CallDirection.outgoing => '건 전화', + CallDirection.missed => '부재중', + }; + lines.addAll([ + Text('통화 ${c.total}번 (받은 통화 ${c.answered} · 부재중 ${c.missed})'), + Text('통화 시간 합계 ${durationText(c.totalSeconds)} · 평균 ${durationText(c.averageSeconds)}'), + Text('마지막: ${last.month}/${last.day} ${last.hour}:${last.minute.toString().padLeft(2, '0')} ($dir)'), + if (c.windows.isNotEmpty) + Text('통화 잘 되는 시간: ${c.windows.map((w) => w.label).join(', ')}', style: const TextStyle(color: Pal.accent)), + ]); + } + if (f.topics.isNotEmpty) lines.add(Text('주로 이야기한 것: ${f.topics.join(', ')}')); + if (f.memo.isNotEmpty) lines.add(Text('메모: ${f.memo}', style: const TextStyle(color: Pal.muted))); + return Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('통화', style: TextStyle(fontSize: 13, color: Pal.muted)), + const SizedBox(height: 6), + for (final w in lines) Padding(padding: const EdgeInsets.symmetric(vertical: 2), child: w), + ]), + ); + } +} + +/// 다가오는 생일·기일. +class EventsPanel extends StatelessWidget { + const EventsPanel(this.events, {super.key}); + final List events; + + @override + Widget build(BuildContext context) { + if (events.isEmpty) return const SizedBox.shrink(); + return Panel( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('다가오는 일정', style: TextStyle(fontSize: 13, color: Pal.muted)), + const SizedBox(height: 6), + for (final e in events) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text('${e.text} · ${e.upcoming.date.month}/${e.upcoming.date.day} (${dayWord(e.upcoming.daysLeft)})' + '${e.upcoming.years == null ? '' : e.upcoming.event.kind == EventKind.birthday ? ' · ${e.upcoming.years}살' : ' · ${e.upcoming.years}주기'}'), + ), + ]), + ); + } +} diff --git a/lib/modules/ui/src/widgets/radar_view.dart b/lib/modules/ui/src/widgets/radar_view.dart new file mode 100644 index 0000000..e301ea8 --- /dev/null +++ b/lib/modules/ui/src/widgets/radar_view.dart @@ -0,0 +1,85 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import '../theme.dart'; + +/// 레이더에 찍을 점 하나: 이름, 방위(북쪽 0 · 시계 방향, 도), 거리(미터). +class RadarDot { + const RadarDot(this.label, this.bearing, this.meters); + final String label; + final double bearing; + final double meters; +} + +/// 내 위치를 가운데 두고 친구들이 어느 쪽 얼마나 떨어져 있는지 보여 준다. 지도 타일 없이 그린다. +/// 거리는 로그 눈금이라 가까운 친구와 먼 친구가 한 화면에 들어온다. +class RadarView extends StatelessWidget { + const RadarView({super.key, required this.dots, this.size = 280}); + + final List dots; + final double size; + + @override + Widget build(BuildContext context) { + return Semantics( + label: dots.isEmpty ? '친구 위치 없음' : '친구 ${dots.length}명의 위치', + child: SizedBox(width: size, height: size, child: CustomPaint(painter: _RadarPainter(dots))), + ); + } +} + +class _RadarPainter extends CustomPainter { + _RadarPainter(this.dots); + + final List dots; + + // 반지름 안쪽부터 100m, 1km, 10km, 100km+ + static const _rings = [100.0, 1000.0, 10000.0, 100000.0]; + + @override + void paint(Canvas canvas, Size size) { + final c = size.center(Offset.zero); + final r = size.width / 2 - 14; + final ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = Pal.line; + for (var i = 1; i <= _rings.length; i++) { + canvas.drawCircle(c, r * i / _rings.length, ring); + } + canvas.drawLine(Offset(c.dx, c.dy - r), Offset(c.dx, c.dy + r), ring); + canvas.drawLine(Offset(c.dx - r, c.dy), Offset(c.dx + r, c.dy), ring); + _label(canvas, '북', Offset(c.dx, c.dy - r - 2), Pal.muted); + canvas.drawCircle(c, 6, Paint()..color = Pal.accent); + + for (final d in dots) { + final f = _radiusFraction(d.meters); + final a = d.bearing * pi / 180; + final p = Offset(c.dx + sin(a) * r * f, c.dy - cos(a) * r * f); + canvas.drawCircle(p, 6, Paint()..color = Pal.school); + _label(canvas, d.label, p + const Offset(0, 8), Pal.text); + } + } + + /// 거리 → 반지름 비율(0~1). 눈금 사이는 로그로 이어 준다. + static double _radiusFraction(double meters) { + if (meters <= 0) return 0; + final m = meters.clamp(1.0, _rings.last); + final lo = log(1) / ln10, hi = log(_rings.last) / ln10; + return ((log(m) / ln10 - lo) / (hi - lo)).clamp(0.0, 1.0); + } + + void _label(Canvas canvas, String text, Offset at, Color color) { + final tp = TextPainter( + text: TextSpan(text: text, style: TextStyle(color: color, fontSize: 11)), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: 80); + tp.paint(canvas, at - Offset(tp.width / 2, 0)); + } + + @override + bool shouldRepaint(_RadarPainter old) => old.dots != dots; +} diff --git a/lib/modules/ui/src/widgets/verify_phone_tile.dart b/lib/modules/ui/src/widgets/verify_phone_tile.dart new file mode 100644 index 0000000..97dbb9d --- /dev/null +++ b/lib/modules/ui/src/widgets/verify_phone_tile.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/core/core.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import 'async_view.dart'; +import 'common.dart'; + +/// 내 번호 확인 줄. 확인되면 "확인됨", 아니면 눌러서 문자로 확인한다. 확인 수단이 없으면 보이지 않는다. +class VerifyPhoneTile extends StatelessWidget { + const VerifyPhoneTile({super.key}); + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context).app; + if (!app.verify.available) return const SizedBox.shrink(); + return AsyncView( + load: (app) => app.verify.isVerified(), + builder: (context, done) => Panel( + onTap: done ? null : () => showDialog(context: context, builder: (_) => const _VerifyDialog()), + child: Row(children: [ + Icon(done ? Icons.verified_outlined : Icons.sms_outlined, color: done ? Pal.school : Pal.accent), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(done ? '내 번호 확인됨' : '내 번호 확인하기'), + Text(done ? '다른 사람이 내 번호로 우편함을 가로챌 수 없어요' : '문자로 온 번호를 넣으면, 이 기기만 내 우편함을 열 수 있어요', + style: const TextStyle(fontSize: 11, color: Pal.muted)), + ]), + ), + if (!done) const Icon(Icons.chevron_right, color: Pal.muted), + ]), + ), + ); + } +} + +class _VerifyDialog extends StatefulWidget { + const _VerifyDialog(); + + @override + State<_VerifyDialog> createState() => _VerifyDialogState(); +} + +class _VerifyDialogState extends State<_VerifyDialog> { + final _code = TextEditingController(); + bool _sent = false; + bool _busy = false; + String? _message; + + @override + void dispose() { + _code.dispose(); + super.dispose(); + } + + Future _request() async { + final svc = AppScope.of(context).app.verify; + setState(() { + _busy = true; + _message = null; + }); + final r = await svc.request(); + if (!mounted) return; + setState(() { + _busy = false; + _sent = r == VerifyStart.sent; + _message = switch (r) { + VerifyStart.sent => '문자를 보냈어요. 5분 안에 번호를 넣어 주세요.', + VerifyStart.rateLimited => '너무 자주 요청했어요. 잠시 뒤에 다시 해 주세요.', + VerifyStart.unavailable => '이 서버는 번호 확인을 쓰지 않아요.', + VerifyStart.failed => '문자를 보내지 못했어요. 연결을 확인해 주세요.', + }; + }); + } + + Future _confirm() async { + final svc = AppScope.of(context).app.verify; + setState(() => _busy = true); + final ok = await svc.confirm(_code.text); + if (!mounted) return; + if (ok) { + Navigator.of(context).pop(); + return; + } + setState(() { + _busy = false; + _message = '번호가 맞지 않거나 시간이 지났어요.'; + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('내 번호 확인'), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (_sent) + TextField(controller: _code, keyboardType: TextInputType.number, maxLength: 6, autofocus: true, decoration: const InputDecoration(labelText: '확인 번호 6자리')) + else + const Text('내 번호로 확인 번호를 문자로 보내요.'), + if (_message != null) + Padding(padding: const EdgeInsets.only(top: 8), child: Text(_message!, style: const TextStyle(fontSize: 12, color: Pal.muted))), + ]), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('닫기')), + if (_sent) TextButton(onPressed: _busy ? null : _request, child: const Text('다시 받기')), + FilledButton(onPressed: _busy ? null : (_sent ? _confirm : _request), child: Text(_sent ? '확인' : '문자 받기')), + ], + ); + } +} diff --git a/lib/modules/ui/src/widgets/visibility_matrix_view.dart b/lib/modules/ui/src/widgets/visibility_matrix_view.dart new file mode 100644 index 0000000..d0bf05a --- /dev/null +++ b/lib/modules/ui/src/widgets/visibility_matrix_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:friendly_map/modules/visibility/visibility.dart'; + +import '../app_scope.dart'; +import '../theme.dart'; +import 'async_view.dart'; + +/// 공개 범위 표: 칸을 누르면 공개 → 일부 → 숨김 순서로 바뀐다. +class VisibilityMatrixView extends StatelessWidget { + const VisibilityMatrixView({super.key}); + + static Color _audienceColor(String a) => switch (a) { + VAudience.family => Pal.accent, + VAudience.company => Pal.company, + VAudience.school => Pal.school, + _ => Pal.group, + }; + + @override + Widget build(BuildContext context) { + final svc = AppScope.of(context).app.visibility; + return AsyncView( + load: (app) => app.visibility.matrix(), + builder: (context, m) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column(children: [ + Row(children: [ + const SizedBox(width: 84), + for (final a in VAudience.all) + Expanded( + child: Text(VAudience.label(a), textAlign: TextAlign.center, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: _audienceColor(a))), + ), + ]), + const SizedBox(height: 6), + for (final c in VCategory.all) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row(children: [ + SizedBox(width: 84, child: Text(VCategory.label(c), style: const TextStyle(fontSize: 13))), + for (final a in VAudience.all) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: _Cell(level: m.get(c, a), onTap: () => svc.cycle(c, a)), + ), + ), + ]), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton(onPressed: svc.reset, child: const Text('처음 값으로')), + ), + ]), + ), + ); + } +} + +class _Cell extends StatelessWidget { + const _Cell({required this.level, required this.onTap}); + + final VLevel level; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final open = level == VLevel.open; + final partial = level == VLevel.partial; + return Semantics( + button: true, + label: level.label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: open ? const Color(0xFF15201F) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: open ? const Color(0xFF24413D) : (partial ? const Color(0xFF5A606A) : Pal.line)), + ), + child: Text(level.label, style: TextStyle(fontSize: 12, fontWeight: open ? FontWeight.w700 : FontWeight.w500, color: open ? Pal.school : (partial ? Pal.text : Pal.muted))), + ), + ), + ); + } +} diff --git a/lib/modules/ui/ui.dart b/lib/modules/ui/ui.dart new file mode 100644 index 0000000..8b64568 --- /dev/null +++ b/lib/modules/ui/ui.dart @@ -0,0 +1,7 @@ +/// 화면: 템플릿 기반 화면과 공통 부품 +/// +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/bootstrap.dart' show Bootstrap; +export 'src/friendly_map_app.dart' show FriendlyMapApp; diff --git a/lib/modules/visibility/src/filter.dart b/lib/modules/visibility/src/filter.dart new file mode 100644 index 0000000..68ee5e1 --- /dev/null +++ b/lib/modules/visibility/src/filter.dart @@ -0,0 +1,49 @@ +import 'package:friendly_map/modules/core/core.dart'; + +import 'matrix.dart'; +import 'model.dart'; + +/// 명함 항목을 받는 사람의 관계에 맞게 걸러 낸다. +/// +/// - [audiences] 가 비어 있으면(관계를 모르는 사람) 내가 직접 골라 보내는 것이므로 그대로 둔다. +/// - 숨김: 항목을 뺀다. 일부: 값을 줄여서(생일은 월, 전화는 끝 4자리 …) 보낸다. +Map applyVisibility(Map fields, Set audiences, VisibilityMatrix matrix) { + if (audiences.isEmpty) return Map.of(fields); + final out = {}; + for (final e in fields.entries) { + final cat = VCategory.ofKey(e.key); + if (cat == null) { + out[e.key] = e.value; + continue; + } + switch (matrix.forAudiences(cat, audiences)) { + case VLevel.open: + out[e.key] = e.value; + case VLevel.hidden: + break; + case VLevel.partial: + final v = _shorten(e.key, e.value); + if (v != null && v.isNotEmpty) out[e.key] = v; + } + } + return out; +} + +String? _shorten(String key, String value) { + switch (key) { + case CardKeys.school: + case CardKeys.company: + return value; // 이름만 알리고 + case CardKeys.birthday: + final m = RegExp(r'(\d{1,2})[-./](\d{1,2})$').firstMatch(value.trim()); + return m == null ? null : '${int.parse(m.group(1)!)}월'; // 월까지만 + case CardKeys.phone: + final digits = value.replaceAll(RegExp(r'\D'), ''); + return digits.length < 4 ? null : '****${digits.substring(digits.length - 4)}'; + case CardKeys.email: + final at = value.indexOf('@'); + return at <= 0 ? null : '${value[0]}***${value.substring(at)}'; + default: + return null; // 과정·연도·전공·직책 등 자세한 것은 뺀다 + } +} diff --git a/lib/modules/visibility/src/matrix.dart b/lib/modules/visibility/src/matrix.dart new file mode 100644 index 0000000..dae5f29 --- /dev/null +++ b/lib/modules/visibility/src/matrix.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; + +import 'model.dart'; + +/// 공개 범위 표: 정보 묶음(행) × 관계 묶음(열) → 공개 단계. +class VisibilityMatrix { + VisibilityMatrix._(this._cells); + + final Map _cells; + + /// 처음 표 (화면 설계의 기본값). + factory VisibilityMatrix.defaults() { + const o = VLevel.open, p = VLevel.partial, h = VLevel.hidden; + const rows = >{ + // 가족, 회사, 동문, 그룹 + VCategory.school: [o, o, o, h], + VCategory.work: [o, o, p, h], + VCategory.birthday: [o, p, h, h], + VCategory.contact: [o, p, h, h], + VCategory.location: [o, h, h, h], + }; + final cells = {}; + rows.forEach((c, levels) { + for (var i = 0; i < VAudience.all.length; i++) { + cells[_k(c, VAudience.all[i])] = levels[i]; + } + }); + return VisibilityMatrix._(cells); + } + + static String _k(String category, String audience) => '$category|$audience'; + + VLevel get(String category, String audience) => _cells[_k(category, audience)] ?? VLevel.hidden; + + /// 칸 하나를 바꾼 새 표. + VisibilityMatrix with_(String category, String audience, VLevel level) => + VisibilityMatrix._({..._cells, _k(category, audience): level}); + + /// 여러 관계에 속한 사람에게는 가장 많이 보여 주는 단계를 쓴다. + VLevel forAudiences(String category, Iterable audiences) { + VLevel? best; + for (final a in audiences) { + final l = get(category, a); + best = best == null ? l : VLevel.moreOpen(best, l); + } + return best ?? VLevel.open; + } + + String toJson() => jsonEncode(_cells.map((k, v) => MapEntry(k, v.name))); + + /// 저장된 글자에서 복원한다. 깨졌거나 없으면 처음 표, 일부 칸이 없으면 처음 값으로 채운다. + factory VisibilityMatrix.fromJson(String? text) { + final base = VisibilityMatrix.defaults(); + if (text == null || text.isEmpty) return base; + try { + final m = jsonDecode(text) as Map; + final cells = {...base._cells}; + for (final e in m.entries) { + final level = VLevel.values.where((v) => v.name == e.value).firstOrNull; + if (level != null && cells.containsKey(e.key)) cells[e.key as String] = level; + } + return VisibilityMatrix._(cells); + } catch (_) { + return base; + } + } +} diff --git a/lib/modules/visibility/src/model.dart b/lib/modules/visibility/src/model.dart new file mode 100644 index 0000000..981ff96 --- /dev/null +++ b/lib/modules/visibility/src/model.dart @@ -0,0 +1,80 @@ +import 'package:friendly_map/modules/core/core.dart'; + +/// 공개 단계. +enum VLevel { + open('공개'), + partial('일부'), + hidden('숨김'); + + const VLevel(this.label); + final String label; + + /// 공개 → 일부 → 숨김 → 공개 순서로 돌린다 (표의 칸을 누를 때). + VLevel get next => VLevel.values[(index + 1) % VLevel.values.length]; + + /// 더 많이 보여 주는 쪽. + static VLevel moreOpen(VLevel a, VLevel b) => a.index <= b.index ? a : b; +} + +/// 정보 묶음 (표의 행). +class VCategory { + static const school = 'school'; + static const work = 'work'; + static const birthday = 'birthday'; + static const contact = 'contact'; + static const location = 'location'; + static const all = [school, work, birthday, contact, location]; + + static String label(String c) => switch (c) { + school => '학교', + work => '회사·직책', + birthday => '생일', + contact => '연락처', + location => '실시간 위치', + _ => c, + }; + + /// 명함 항목 이름 → 정보 묶음. 이름·가족 관계처럼 어느 묶음에도 없으면 null (항상 보인다). + static String? ofKey(String key) => switch (key) { + CardKeys.school || CardKeys.level || CardKeys.year || CardKeys.major => school, + CardKeys.company || CardKeys.position || CardKeys.former => work, + CardKeys.birthday => birthday, + CardKeys.phone || CardKeys.email => contact, + _ => null, + }; +} + +/// 받는 사람의 관계 묶음 (표의 열). +class VAudience { + static const family = 'family'; + static const company = 'company'; + static const school = 'school'; + static const group = 'group'; + static const all = [family, company, school, group]; + + static String label(String a) => switch (a) { + family => '가족', + company => '회사', + school => '동문', + group => '그룹', + _ => a, + }; + + /// 연결 관계 글자(예: "가족:어머니", "동창", "전 직장 동료", "같은 모임")에서 관계 묶음을 찾는다. + static String? ofRelation(String relation) { + if (relation.startsWith('가족')) return family; + if (relation.contains('직장') || relation.contains('회사') || relation.contains('동료')) return company; + if (relation.contains('동창') || relation.contains('동문')) return school; + if (relation.contains('모임') || relation.contains('여행') || relation.contains('그룹')) return group; + return null; + } + + /// 명함 종류 → 관계 묶음. 개인 명함은 특정 관계를 뜻하지 않아 null. + static String? ofCardKind(String kind) => switch (kind) { + CardKinds.family => family, + CardKinds.company => company, + CardKinds.school => school, + CardKinds.group => group, + _ => null, + }; +} diff --git a/lib/modules/visibility/visibility.dart b/lib/modules/visibility/visibility.dart new file mode 100644 index 0000000..93d0190 --- /dev/null +++ b/lib/modules/visibility/visibility.dart @@ -0,0 +1,9 @@ +/// 공개 범위: 정보 묶음 × 관계 묶음 표와, 명함을 받는 사람에 맞게 거르는 함수 +/// +/// 순수 계산만 한다(저장·화면 없음). core 의 명함 항목 이름만 쓴다. +/// 이 파일은 모듈의 공개 입구다. 다른 모듈은 이 파일만 import 한다. +library; + +export 'src/filter.dart'; +export 'src/matrix.dart'; +export 'src/model.dart'; diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..6b97129 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,938 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + bip39: + dependency: "direct main" + description: + name: bip39 + sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc + url: "https://pub.dev" + source: hosted + version: "1.0.6" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + call_log: + dependency: "direct main" + description: + name: call_log + sha256: "6ad3e241efa2bc82febc537fe11112a62c94eb5db826bd532500574ba34a78a2" + url: "https://pub.dev" + source: hosted + version: "6.0.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "2b3bc0f7320d5a5fa02f8209ffcf1aaa7c2cabb7b2bedbc0cfeb5ff2778b8b09" + url: "https://pub.dev" + source: hosted + version: "14.1.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: e67583809a1fab5f4a2255902978e2bc095ace9451fde0d9624e54facd0a5c22 + url: "https://pub.dev" + source: hosted + version: "5.2.1" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: a1e177f3081089724f2095856aeb96eb8d7d1c59e425aed8dd4de7aa2c11ab66 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: e012af6b7e2674e7500237ab11e44ac9119e87fd0105e7bb55cde1750e8e5d80 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "5a51ea79f0cf8293f616a5d08baae185b59b5e6bb24df01f2a63037ade0bade0" + url: "https://pub.dev" + source: hosted + version: "7.4.2" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nsd: + dependency: "direct main" + description: + name: nsd + sha256: "2551fec29f6032459815a577f385f89e18099a3ca1c545b5dfb1e1a64b5705ec" + url: "https://pub.dev" + source: hosted + version: "5.0.1" + nsd_android: + dependency: transitive + description: + name: nsd_android + sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_ios: + dependency: transitive + description: + name: nsd_ios + sha256: "57a4b8b218860346eba7e2d629c6fce1fa89a97645350130a15c45babb4b1324" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + nsd_macos: + dependency: transitive + description: + name: nsd_macos + sha256: cca373ee5f28ea22140d595b5cd06e83ebc2d6e4ee3e17d7fa1fd6564e4cb352 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + nsd_platform_interface: + dependency: transitive + description: + name: nsd_platform_interface + sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_windows: + dependency: transitive + description: + name: nsd_windows + sha256: "68b4a256b0be258dbbad0ae789f2e8838d0935a353dac86b17c14c1a05df4ecd" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sensors_plus: + dependency: "direct main" + description: + name: sensors_plus + sha256: "50a01f66846bff68de947bd5df225c3627c923c467f758867f3916350be059ac" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + sensors_plus_platform_interface: + dependency: transitive + description: + name: sensors_plus_platform_interface + sha256: "9e12a92569bf14ae04b2f086c3e087177a47e527b7666ae1bbe5f70f8450ab19" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite_common: + dependency: "direct main" + description: + name: sqflite_common + sha256: b80e94ff0d551d5f1f8c7d14348d29b576cca3ae30f6e195a7c380552c32b1e0 + url: "https://pub.dev" + source: hosted + version: "2.5.14" + sqflite_common_ffi: + dependency: "direct dev" + description: + name: sqflite_common_ffi + sha256: "119d730460264a1f11f414d9eaa43fb686789357e81ce35f9e8ccb66ddd040e4" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + sqflite_sqlcipher: + dependency: "direct main" + description: + name: sqflite_sqlcipher + sha256: "50d05fe0495ed96a85503d9850a49c0936502f44fdba96f403c97e4c9c7043e7" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + web: + dependency: transitive + description: + name: web + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..4b6cc40 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,35 @@ +name: friendly_map +description: Friendly Map - 인맥 확인 앱 (서버 없이 기기끼리 공유) +publish_to: "none" +version: 0.1.0+1 + +environment: + sdk: ">=3.3.0 <4.0.0" + +# 버전은 `flutter pub get` 후 pubspec.lock 기준으로 고정한다 (아직 any). +dependencies: + flutter: + sdk: flutter + sqflite_common: any # 저장소: SQLite 공통 약속 (모든 플랫폼) + sqflite_sqlcipher: any # 안드로이드: 암호화된 SQLite (SQLCipher) + flutter_secure_storage: any # 키: 안드로이드 보안 저장소 + cryptography: any # 키: 암호화·키 생성 + bip39: any # 키: 단어 12개 복구 코드 + nsd: any # 발견: 같은 네트워크 자동 발견 + mobile_scanner: any # 발견: QR 스캔 + qr_flutter: any # 발견: QR 표시 + sensors_plus: any # 발견: 흔들기 감지 + geolocator: any # 위치: 안드로이드 현재 위치 + call_log: any # 통화: 안드로이드 통화 기록 + url_launcher: any # 통화: 전화·문자 화면 열기 + flutter_local_notifications: ^17.2.4 # 알림: 예약 알림 + timezone: ^0.9.4 # 알림: 예약 시각 계산 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: any + sqflite_common_ffi: any # 테스트: 컴퓨터에서 SQLite 로 검사 + +flutter: + uses-material-design: true diff --git a/server/Containerfile b/server/Containerfile new file mode 100644 index 0000000..a90a58c --- /dev/null +++ b/server/Containerfile @@ -0,0 +1,7 @@ +# 보관함 서버 이미지: podman build -t fm-mailbox -f Containerfile . +FROM python:3.12-slim +WORKDIR /app +COPY mailbox_server.py mailbox_store.py ./ +VOLUME /data +EXPOSE 8080 +CMD ["python3", "mailbox_server.py", "--port", "8080", "--db", "/data/mailbox.sqlite3"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..f54dd17 --- /dev/null +++ b/server/README.md @@ -0,0 +1,27 @@ +# 보관함 서버 + +잠긴 봉투를 받는 사람이 접속할 때까지 잠시 맡아 두는 우체통입니다. +봉투 속은 기기끼리만 풀 수 있어서 서버는 내용을 읽지 못합니다. + +## 실행 (파이썬만 있으면 됨) + + python3 mailbox_server.py --port 8080 --db mailbox.sqlite3 + +## 컨테이너로 실행 + + podman build -t fm-mailbox -f Containerfile . + podman run -d -p 8080:8080 -v fm-data:/data fm-mailbox + +## 시험 + + python3 test_mailbox.py + +## 알아둘 점 + +- 저장 방식은 `mailbox_store.py` 의 `MailboxStore` 약속만 지키면 바꿀 수 있습니다 (클라우드로 옮길 때 이 파일만 교체). +- 기본: 우편함은 처음 연 기기의 비밀값에 묶입니다(번호를 먼저 선점당할 수 있음). +- 문자 확인을 켜면(`--sms-webhook <주소>` + `--require-verify`) 문자로 번호를 확인한 기기만 우편함을 열 수 있어 선점이 막힙니다. + 문자 발송은 웹훅 하나(`POST {"to":"+82...","text":"..."}`, 선택적으로 `--sms-webhook-token`)라 업체에 묶이지 않습니다. + `phone_verify.py` 의 `SmsSender` 를 바꾸면 다른 방식도 쓸 수 있습니다. 시험용 `--sms-log` 는 실서비스에서 쓰지 마세요. +- 인터넷에 열 때는 앞에 HTTPS 를 붙이세요 (Caddy, nginx 등). +- 봉투는 30일 안에 못 받아 가면 정리되고, 받는 사람 한 명당 5000통까지 맡습니다. diff --git a/server/mailbox_server.py b/server/mailbox_server.py new file mode 100644 index 0000000..f49ab6c --- /dev/null +++ b/server/mailbox_server.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Friendly Map 보관함 서버 (잠긴 봉투를 잠시 맡아 두는 우체통). + +- 봉투 속은 기기끼리만 풀 수 있어서 서버는 내용을 읽을 수 없다. 겉(보낸·받는 번호, 종류, 단위 ID)만 본다. +- 외부 의존 없음 (파이썬 표준 라이브러리만). 실행: + python3 mailbox_server.py --port 8080 --db mailbox.sqlite3 +""" +import argparse +import json +import re +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from mailbox_store import MailboxStore, SqliteMailboxStore +from phone_verify import LogSmsSender, Verifier, WebhookSmsSender + +MAX_BODY = 1024 * 1024 # 봉투 하나 최대 1MB +KEEP_SECONDS = 30 * 24 * 3600 # 30일 지나도 못 받아 간 봉투는 정리 +PHONE = re.compile(r"^\+\d{8,15}$") +REQUIRED = ("id", "from", "to", "kind", "unit", "body", "stamp") + + +def make_handler(store: MailboxStore, verifier=None, strict=False): + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): # 조용히 + pass + + def _send(self, code, obj): + data = json.dumps(obj).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _read_json(self): + n = int(self.headers.get("Content-Length") or 0) + if n <= 0 or n > MAX_BODY: + return None + try: + return json.loads(self.rfile.read(n).decode("utf-8")) + except Exception: + return None + + def do_GET(self): + u = urlparse(self.path) + if u.path == "/v1/time": + return self._send(200, {"stamp": int(time.time() * 100)}) + if u.path == "/v1/fetch": + to = (parse_qs(u.query).get("to") or [""])[0] + if not PHONE.match(to): + return self._send(400, {"error": "bad phone"}) + if not store.check_token(to, self.headers.get("X-Fm-Token", ""), strict): + return self._send(403, {"error": "token"}) + items = [json.loads(text) for _, text in store.take(to)] + return self._send(200, {"items": items}) + self._send(404, {"error": "not found"}) + + def do_POST(self): + u = urlparse(self.path) + body = self._read_json() + if body is None: + return self._send(400, {"error": "bad body"}) + if u.path == "/v1/send": + if not all(k in body for k in REQUIRED) or not PHONE.match(str(body["to"])): + return self._send(400, {"error": "bad envelope"}) + ok = store.put(body["to"], str(body["id"]), json.dumps(body, ensure_ascii=False)) + return self._send(200 if ok else 429, {"ok": ok}) + if u.path == "/v1/ack": + to = str(body.get("to", "")) + if not PHONE.match(to) or not store.check_token(to, self.headers.get("X-Fm-Token", ""), strict): + return self._send(403, {"error": "token"}) + store.ack(to, [str(i) for i in body.get("ids", [])]) + return self._send(200, {"ok": True}) + if u.path == "/v1/verify/start": + phone = str(body.get("phone", "")) + if verifier is None or not PHONE.match(phone): + return self._send(400, {"error": "unavailable"}) + r = verifier.start(phone) + return self._send({"ok": 200, "rate": 429, "fail": 502}[r], {"ok": r == "ok", "result": r}) + if u.path == "/v1/verify/confirm": + phone, code, token = str(body.get("phone", "")), str(body.get("code", "")), str(body.get("token", "")) + if verifier is None or not PHONE.match(phone) or len(token) < 16: + return self._send(400, {"error": "bad request"}) + if not verifier.confirm(phone, code): + return self._send(403, {"ok": False}) + store.bind_token(phone, token) + return self._send(200, {"ok": True}) + self._send(404, {"error": "not found"}) + + return Handler + + +def make_server(store: MailboxStore, host="127.0.0.1", port=0, verifier=None, strict=False): + return ThreadingHTTPServer((host, port), make_handler(store, verifier, strict)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="0.0.0.0") + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--db", default="mailbox.sqlite3") + ap.add_argument("--sms-webhook", help="문자 발송 주소 (POST {to,text}). 있으면 문자 확인을 켠다") + ap.add_argument("--sms-webhook-token", default=None) + ap.add_argument("--sms-log", nargs="?", const="", default=None, help="시험용: 문자를 보내지 않고 기록 (실서비스 금지)") + ap.add_argument("--require-verify", action="store_true", help="문자 확인을 마친 기기만 우편함을 열 수 있게 한다") + a = ap.parse_args() + verifier = None + if a.sms_webhook: + verifier = Verifier(WebhookSmsSender(a.sms_webhook, a.sms_webhook_token)) + elif a.sms_log is not None: + verifier = Verifier(LogSmsSender(a.sms_log or None)) + if a.require_verify and verifier is None: + ap.error("--require-verify 에는 --sms-webhook 이 필요합니다") + store = SqliteMailboxStore(a.db) + srv = make_server(store, a.host, a.port, verifier, a.require_verify) + + def cleaner(): + while True: + time.sleep(3600) + store.purge(KEEP_SECONDS) + + threading.Thread(target=cleaner, daemon=True).start() + print(f"listening {srv.server_address[1]}", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/server/mailbox_store.py b/server/mailbox_store.py new file mode 100644 index 0000000..eb9d9fc --- /dev/null +++ b/server/mailbox_store.py @@ -0,0 +1,102 @@ +"""보관함 저장소. 잠긴 봉투를 받는 사람별로 잠시 맡아 둔다. + +저장 방식은 이 파일의 MailboxStore 약속만 지키면 갈아 끼울 수 있다 +(처음엔 SQLite 파일, 나중에 클라우드 저장소로 옮길 때 이 파일만 바꾼다). +""" +import sqlite3 +import threading +import time + + +class MailboxStore: + """약속: 서버는 아래 메서드만 쓴다.""" + + def put(self, to, envelope_id, text): # 맡기기. 같은 id 는 한 번만. + raise NotImplementedError + + def take(self, to): # 받는 사람의 봉투 목록 [(id, text)] + raise NotImplementedError + + def ack(self, to, ids): # 받아 갔으니 지우기 + raise NotImplementedError + + def check_token(self, to, token, strict=False): # 처음 쓴 기기에 묶는다(strict 면 문자 확인으로 묶인 토큰만). 맞으면 True + raise NotImplementedError + + def bind_token(self, to, token): # 문자 확인을 마친 기기의 토큰으로 바꾼다 + raise NotImplementedError + + def purge(self, older_than_seconds): # 오래된 봉투 정리 + raise NotImplementedError + + +class SqliteMailboxStore(MailboxStore): + def __init__(self, path=":memory:", max_per_recipient=5000): + self._db = sqlite3.connect(path, check_same_thread=False) + self._lock = threading.Lock() + self._max = max_per_recipient + with self._lock: + self._db.executescript( + """ + CREATE TABLE IF NOT EXISTS mail( + to_phone TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL, + at REAL NOT NULL, PRIMARY KEY(to_phone, id)); + CREATE TABLE IF NOT EXISTS tokens( + to_phone TEXT PRIMARY KEY, token TEXT NOT NULL); + """ + ) + cols = [r[1] for r in self._db.execute("PRAGMA table_info(tokens)").fetchall()] + if "verified" not in cols: # 예전 DB 이어 쓰기 + self._db.execute("ALTER TABLE tokens ADD COLUMN verified INTEGER NOT NULL DEFAULT 0") + self._db.commit() + + def put(self, to, envelope_id, text): + with self._lock: + n = self._db.execute("SELECT COUNT(*) FROM mail WHERE to_phone=?", (to,)).fetchone()[0] + if n >= self._max: + return False + self._db.execute( + "INSERT OR IGNORE INTO mail(to_phone,id,body,at) VALUES(?,?,?,?)", + (to, envelope_id, text, time.time()), + ) + self._db.commit() + return True + + def take(self, to): + with self._lock: + rows = self._db.execute( + "SELECT id, body FROM mail WHERE to_phone=? ORDER BY at, rowid", (to,) + ).fetchall() + return [(r[0], r[1]) for r in rows] + + def ack(self, to, ids): + with self._lock: + self._db.executemany("DELETE FROM mail WHERE to_phone=? AND id=?", [(to, i) for i in ids]) + self._db.commit() + + def check_token(self, to, token, strict=False): + if not token: + return False + with self._lock: + row = self._db.execute("SELECT token, verified FROM tokens WHERE to_phone=?", (to,)).fetchone() + if strict: + return row is not None and row[1] == 1 and row[0] == token + if row is None: + self._db.execute("INSERT INTO tokens(to_phone,token) VALUES(?,?)", (to, token)) + self._db.commit() + return True + return row[0] == token + + def bind_token(self, to, token): + with self._lock: + self._db.execute( + "INSERT INTO tokens(to_phone,token,verified) VALUES(?,?,1) " + "ON CONFLICT(to_phone) DO UPDATE SET token=excluded.token, verified=1", + (to, token), + ) + self._db.commit() + + def purge(self, older_than_seconds): + with self._lock: + self._db.execute("DELETE FROM mail WHERE at < ?", (time.time() - older_than_seconds,)) + self._db.commit() diff --git a/server/phone_verify.py b/server/phone_verify.py new file mode 100644 index 0000000..0718aab --- /dev/null +++ b/server/phone_verify.py @@ -0,0 +1,104 @@ +"""전화번호 확인(문자 인증). 문자 발송 방법은 SmsSender 하나만 바꾸면 된다 (업체에 묶이지 않는다). + +- 6자리 번호를 문자로 보내고, 5분 안에 맞게 넣으면 그 기기의 토큰이 번호에 묶인다. +- 번호당 1시간에 3번까지만 보낼 수 있고, 번호 하나에 5번 틀리면 그 번호는 새로 요청해야 한다. +- 서버에는 번호 자체가 아니라 소금 친 해시만 남긴다. +""" +import hashlib +import hmac +import json +import secrets +import threading +import time +import urllib.request + + +class SmsSender: + """문자 보내기 약속. send 가 True 를 돌려주면 보낸 것으로 본다.""" + + def send(self, phone: str, text: str) -> bool: + raise NotImplementedError + + +class LogSmsSender(SmsSender): + """개발·시험용: 문자를 보내지 않고 파일(없으면 표준 출력)에 적는다. 실제 서비스에서 쓰지 말 것.""" + + def __init__(self, path=None): + self.path = path + + def send(self, phone, text): + line = f"{phone}\t{text}\n" + if self.path: + with open(self.path, "a", encoding="utf-8") as f: + f.write(line) + else: + print("SMS", line, end="", flush=True) + return True + + +class WebhookSmsSender(SmsSender): + """업체 중립: 주소 하나로 {"to":..., "text":...} 를 POST 한다. 업체별 변환은 그 주소 뒤에서 한다.""" + + def __init__(self, url, bearer=None, timeout=8): + self.url, self.bearer, self.timeout = url, bearer, timeout + + def send(self, phone, text): + req = urllib.request.Request( + self.url, method="POST", data=json.dumps({"to": phone, "text": text}).encode("utf-8"), + headers={"Content-Type": "application/json", **({"Authorization": f"Bearer {self.bearer}"} if self.bearer else {})}, + ) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as r: + return 200 <= r.status < 300 + except Exception: + return False + + +class Verifier: + CODE_TTL = 300 + MAX_SENDS = 3 + SEND_WINDOW = 3600 + MAX_TRIES = 5 + + def __init__(self, sender: SmsSender, clock=time.time): + self._sender, self._clock = sender, clock + self._lock = threading.Lock() + self._pending = {} # phone -> [salt, hash, expires, tries] + self._sends = {} # phone -> [시각...] + + @staticmethod + def _hash(salt: str, code: str) -> str: + return hashlib.sha256((salt + code).encode()).hexdigest() + + def start(self, phone: str) -> str: + """'ok' | 'rate' | 'fail'""" + now = self._clock() + with self._lock: + sent = [t for t in self._sends.get(phone, []) if now - t < self.SEND_WINDOW] + if len(sent) >= self.MAX_SENDS: + self._sends[phone] = sent + return "rate" + code = f"{secrets.randbelow(1_000_000):06d}" + salt = secrets.token_hex(8) + self._pending[phone] = [salt, self._hash(salt, code), now + self.CODE_TTL, 0] + self._sends[phone] = sent + [now] + ok = self._sender.send(phone, f"[Friendly Map] 확인 번호 {code} (5분 안에 입력)") + if not ok: + with self._lock: + self._pending.pop(phone, None) + self._sends[phone] = self._sends[phone][:-1] + return "fail" + return "ok" + + def confirm(self, phone: str, code: str) -> bool: + now = self._clock() + with self._lock: + p = self._pending.get(phone) + if p is None or now > p[2] or p[3] >= self.MAX_TRIES: + self._pending.pop(phone, None) + return False + p[3] += 1 + if hmac.compare_digest(p[1], self._hash(p[0], code.strip())): + self._pending.pop(phone, None) + return True + return False diff --git a/server/test_mailbox.py b/server/test_mailbox.py new file mode 100644 index 0000000..f9943c8 --- /dev/null +++ b/server/test_mailbox.py @@ -0,0 +1,69 @@ +import json +import threading +import unittest +import urllib.error +import urllib.request + +from mailbox_server import make_server +from mailbox_store import SqliteMailboxStore + + +def env(i, to="+821022223333"): + return {"id": i, "from": "+821011112222", "to": to, "kind": "chat.message", + "unit": "room1", "body": "SEALED", "stamp": 1} + + +class MailboxTest(unittest.TestCase): + def setUp(self): + self.store = SqliteMailboxStore(max_per_recipient=3) + self.srv = make_server(self.store) + self.base = f"http://127.0.0.1:{self.srv.server_address[1]}" + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + + def tearDown(self): + self.srv.shutdown() + + def call(self, method, path, body=None, token=None): + req = urllib.request.Request(self.base + path, method=method, + data=json.dumps(body).encode() if body is not None else None) + req.add_header("Content-Type", "application/json") + if token: + req.add_header("X-Fm-Token", token) + try: + with urllib.request.urlopen(req) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + def test_send_fetch_ack(self): + self.assertEqual(self.call("POST", "/v1/send", env("a"))[0], 200) + self.assertEqual(self.call("POST", "/v1/send", env("a"))[0], 200) # 같은 id 는 한 번만 + code, res = self.call("GET", "/v1/fetch?to=%2B821022223333", token="T1") + self.assertEqual(code, 200) + self.assertEqual([e["id"] for e in res["items"]], ["a"]) + self.call("POST", "/v1/ack", {"to": "+821022223333", "ids": ["a"]}, token="T1") + self.assertEqual(self.call("GET", "/v1/fetch?to=%2B821022223333", token="T1")[1]["items"], []) + + def test_other_token_is_refused(self): + self.call("GET", "/v1/fetch?to=%2B821022223333", token="T1") + self.assertEqual(self.call("GET", "/v1/fetch?to=%2B821022223333", token="T2")[0], 403) + self.assertEqual(self.call("GET", "/v1/fetch?to=%2B821022223333")[0], 403) + + def test_bad_input(self): + self.assertEqual(self.call("POST", "/v1/send", {"id": "x"})[0], 400) + self.assertEqual(self.call("POST", "/v1/send", env("b", to="abc"))[0], 400) + self.assertEqual(self.call("GET", "/v1/fetch?to=abc", token="T")[0], 400) + + def test_limit_per_recipient(self): + for i in "abc": + self.assertEqual(self.call("POST", "/v1/send", env(i))[0], 200) + self.assertEqual(self.call("POST", "/v1/send", env("d"))[0], 429) + + def test_time(self): + code, res = self.call("GET", "/v1/time") + self.assertEqual(code, 200) + self.assertGreater(res["stamp"], 1_700_000_000 * 100) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/test_verify.py b/server/test_verify.py new file mode 100644 index 0000000..6aa977f --- /dev/null +++ b/server/test_verify.py @@ -0,0 +1,157 @@ +import json +import re +import threading +import unittest +from urllib.parse import quote +import urllib.error +import urllib.request + +from mailbox_server import make_server +from mailbox_store import SqliteMailboxStore +from phone_verify import SmsSender, Verifier + +PHONE = "+821022223333" +TOKEN_A = "a" * 32 +TOKEN_B = "b" * 32 + + +class Capture(SmsSender): + def __init__(self): + self.sent, self.ok = [], True + + def send(self, phone, text): + if self.ok: + self.sent.append((phone, text)) + return self.ok + + def last_code(self): + return re.search(r"\b(\d{6})\b", self.sent[-1][1]).group(1) + + +class Clock: + def __init__(self): + self.t = 1000.0 + + def __call__(self): + return self.t + + +class VerifierTest(unittest.TestCase): + def setUp(self): + self.sms, self.clock = Capture(), Clock() + self.v = Verifier(self.sms, self.clock) + + def test_correct_code_confirms_once(self): + self.assertEqual(self.v.start(PHONE), "ok") + code = self.sms.last_code() + self.assertTrue(self.v.confirm(PHONE, code)) + self.assertFalse(self.v.confirm(PHONE, code)) # 한 번 쓴 번호는 못 쓴다 + + def test_wrong_code_and_tries_limit(self): + self.v.start(PHONE) + code = self.sms.last_code() + for _ in range(5): + self.assertFalse(self.v.confirm(PHONE, "000000" if code != "000000" else "111111")) + self.assertFalse(self.v.confirm(PHONE, code)) # 5번 틀리면 맞는 번호도 거절 + + def test_expires_after_five_minutes(self): + self.v.start(PHONE) + code = self.sms.last_code() + self.clock.t += 301 + self.assertFalse(self.v.confirm(PHONE, code)) + + def test_send_rate_limit_per_hour(self): + for _ in range(3): + self.assertEqual(self.v.start(PHONE), "ok") + self.assertEqual(self.v.start(PHONE), "rate") + self.clock.t += 3601 + self.assertEqual(self.v.start(PHONE), "ok") + + def test_sender_failure_does_not_count(self): + self.sms.ok = False + self.assertEqual(self.v.start(PHONE), "fail") + self.sms.ok = True + for _ in range(3): + self.assertEqual(self.v.start(PHONE), "ok") + + def test_new_request_replaces_old_code(self): + self.v.start(PHONE) + old = self.sms.last_code() + self.v.start(PHONE) + new = self.sms.last_code() + if old != new: + self.assertFalse(self.v.confirm(PHONE, old)) + self.assertTrue(self.v.confirm(PHONE, new) or old == new) + + +class ServerVerifyTest(unittest.TestCase): + def setUp(self): + self.sms = Capture() + self.store = SqliteMailboxStore() + self.srv = make_server(self.store, verifier=Verifier(self.sms), strict=True) + self.base = f"http://127.0.0.1:{self.srv.server_address[1]}" + threading.Thread(target=self.srv.serve_forever, daemon=True).start() + + def tearDown(self): + self.srv.shutdown() + + def call(self, method, path, body=None, token=None): + req = urllib.request.Request(self.base + path, method=method, + data=json.dumps(body).encode() if body is not None else None) + req.add_header("Content-Type", "application/json") + if token: + req.add_header("X-Fm-Token", token) + try: + with urllib.request.urlopen(req) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read()) + + def verify(self, token): + self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": PHONE})[0], 200) + return self.call("POST", "/v1/verify/confirm", {"phone": PHONE, "code": self.sms.last_code(), "token": token}) + + def test_strict_mode_blocks_unverified_squatter(self): + self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_A)[0], 403) # 선점 시도 막힘 + + def test_verified_device_opens_mailbox(self): + self.assertEqual(self.verify(TOKEN_A)[0], 200) + self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_A)[0], 200) + self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_B)[0], 403) + + def test_real_owner_takes_over_after_new_verification(self): + self.verify(TOKEN_A) + self.assertEqual(self.verify(TOKEN_B)[0], 200) # 폰을 바꾼 진짜 주인 + self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_B)[0], 200) + self.assertEqual(self.call("GET", f"/v1/fetch?to={quote(PHONE)}", token=TOKEN_A)[0], 403) + + def test_wrong_code_rejected_and_short_token_rejected(self): + self.call("POST", "/v1/verify/start", {"phone": PHONE}) + self.assertEqual(self.call("POST", "/v1/verify/confirm", {"phone": PHONE, "code": "123", "token": TOKEN_A})[0], 403) + self.assertEqual(self.call("POST", "/v1/verify/confirm", {"phone": PHONE, "code": self.sms.last_code(), "token": "짧음"})[0], 400) + + def test_rate_limit_returns_429(self): + for _ in range(3): + self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": PHONE})[0], 200) + self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": PHONE})[0], 429) + + def test_bad_phone_rejected(self): + self.assertEqual(self.call("POST", "/v1/verify/start", {"phone": "010-1234"})[0], 400) + + +class NoVerifierTest(unittest.TestCase): + def test_endpoints_unavailable_and_tofu_still_works(self): + store = SqliteMailboxStore() + srv = make_server(store) + threading.Thread(target=srv.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{srv.server_address[1]}" + req = urllib.request.Request(base + "/v1/verify/start", method="POST", data=json.dumps({"phone": PHONE}).encode()) + with self.assertRaises(urllib.error.HTTPError) as cm: + urllib.request.urlopen(req) + self.assertEqual(cm.exception.code, 400) + self.assertTrue(store.check_token(PHONE, TOKEN_A)) # 문자 확인을 안 쓰면 예전처럼 처음 쓴 기기에 묶인다 + srv.shutdown() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/app_test.dart b/test/app_test.dart new file mode 100644 index 0000000..6a7406f --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,329 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'support/app_device.dart'; + +const pa = '+821011112222'; +const pb = '+821022223333'; + +Future<(AppDevice, AppDevice)> pair({String? modeB}) async { + final hub = MemoryHub(); + final a = await AppDevice.create(hub, '010-1111-2222', pa, '가'); + final b = await AppDevice.create(hub, '010-2222-3333', pb, '나'); + if (modeB != null) await b.app.settings.update(exchangeMode: modeB); + return (a, b); +} + +Future befriend(AppDevice a, AppDevice b) async { + await a.app.friends.add(b.phone, b.name); + await AppDevice.settleAll(); + await b.app.friends.accept(a.phone); + await AppDevice.settleAll(); +} + +void main() { + group('처음 시작', () { + test('번호·이름을 정하면 종류별 기본 명함 5장이 생기고, 개인 명함만 새 친구에게 기본 공유', () async { + final (a, _) = await pair(); + final cards = await a.app.cards.mine(); + expect(cards.map((c) => c.kind).toList(), ['personal', 'company', 'school', 'group', 'family']); + expect(cards.where((c) => c.shareDefault).map((c) => c.kind).toList(), ['personal']); + expect(decodeFields(cards.first.fieldsJson)['name'], '가'); + expect((await a.app.profile.me())!.phone, pa); + }); + + test('올바르지 않은 번호·이름은 거절', () async { + final (a, _) = await pair(); + expect(await a.app.profile.setUp('abc', '가'), null); + expect(await a.app.profile.setUp('010-1111-2222', ' '), null); + }); + }); + + group('친구 추가와 명함 교환', () { + test('요청 후 수락하면 서로 키를 알고, 개인 명함이 서로에게 간다', () async { + final (a, b) = await pair(); + final added = await a.app.friends.add(pb, '나'); + expect(added!.publicKey, null); + await AppDevice.settleAll(); + final req = (await b.app.friends.requests()).single; + expect(req.phone, pa); + expect(req.name, '가'); + await b.app.friends.accept(pa); + await AppDevice.settleAll(); + final bOfA = (await a.app.friends.person(added.id))!; + expect(bOfA.publicKey, b.key); + final aOfB = (await b.app.friends.people()).single; + expect(aOfB.publicKey, a.key); + final got = await b.app.deps.storage.receivedCards.byPerson(aOfB.id); + expect(got.single.kind, 'personal'); + expect(await b.app.friends.requests(), isEmpty); + final back = await a.app.deps.storage.receivedCards.byPerson(bOfA.id); + expect(back.single.kind, 'personal'); + }); + + test('자동 수락 설정이면 요청 없이 바로 친구가 됨', () async { + final (a, b) = await pair(modeB: 'auto'); + final added = await a.app.friends.add(pb, '나'); + await AppDevice.settleAll(); + expect(await b.app.friends.requests(), isEmpty); + expect((await b.app.friends.people()).single.phone, pa); + expect((await a.app.friends.person(added!.id))!.publicKey, b.key); + }); + + test('거절하면 친구가 되지 않음', () async { + final (a, b) = await pair(); + await a.app.friends.add(pb, '나'); + await AppDevice.settleAll(); + await b.app.friends.decline(pa); + expect(await b.app.friends.requests(), isEmpty); + expect(await b.app.friends.people(), isEmpty); + }); + + test('QR 로 추가하면 키를 바로 알고, 내 QR 은 읽으면 같은 정보', () async { + final (a, b) = await pair(); + final qr = (await b.app.friends.myQr())!.encode(); + final p = await a.app.friends.addFromQr(qr); + expect(p!.phone, pb); + expect(p.publicKey, b.key); + expect(await a.app.friends.addFromQr('https://x.y'), null); + }); + + test('번호 확인: 형식 오류, 내 번호, 같은 번호·이름 변경 후보', () async { + final (a, b) = await pair(); + expect((await a.app.friends.check('abc', '나')).error, isNotNull); + expect((await a.app.friends.check('010-1111-2222', '나')).error, '내 번호예요'); + await a.app.friends.add(pb, '나'); + final r = await a.app.friends.check('010 2222 3333', '나 아님'); + expect(r.match!.kind, MatchKind.nameDiffers); + final c = await a.app.friends.check('010-9999-8888', '나'); + expect(c.match!.kind, MatchKind.phoneDiffers); + final same = await a.app.friends.add(c.phone!, '나', samePersonId: c.match!.candidates.first.id); + expect(same!.phone, '+821099998888'); + expect(await a.app.deps.storage.people.byPhone(pb), isNotNull); // 이전 번호로도 찾아짐 + expect(b.phone, pb); + }); + }); + + group('숨기기·근처 사람', () { + test('모든 정보 숨기기를 켜면 모르는 사람의 요청은 쌓이지 않고, 아는 사람은 그대로', () async { + final (a, b) = await pair(); + await b.app.settings.setHideAll(true); + expect(await b.app.settings.hideAll(), true); + await a.app.friends.add(pb, '나'); + await AppDevice.settleAll(); + expect(await b.app.friends.requests(), isEmpty); + await b.app.settings.setHideAll(false); + await a.app.friends.add(pb, '나'); + await AppDevice.settleAll(); + expect((await b.app.friends.requests()).single.phone, pa); + }); + + test('근처에서 번호로만 추가한 사람은 답 인사가 오면 진짜 이름으로 바뀜', () async { + final (a, b) = await pair(modeB: 'auto'); + final p = await a.app.friends.add(pb, pb); // 이름 대신 번호 + expect(p!.name, pb); + await AppDevice.settleAll(); + expect((await a.app.friends.person(p.id))!.name, '나'); + }); + }); + + group('수락 전에 정한 공유', () { + test('상대가 수락하기 전에 고른 회사 명함도 수락 뒤에 전달됨', () async { + final (a, b) = await pair(); + final added = await a.app.friends.add(pb, '나'); + final company = (await a.app.cards.mine()).firstWhere((c) => c.kind == 'company'); + await a.app.cards.shareWith(added!.id, [company.id]); // 아직 키를 모름 + await AppDevice.settleAll(); + await b.app.friends.accept(pa); + await AppDevice.settleAll(); + final aOfB = (await b.app.friends.people()).single; + final kinds = (await b.app.network.receivedCards(aOfB.id)).map((c) => c.kind).toSet(); + expect(kinds, {'personal', 'company'}); + }); + }); + + group('근처 사람', () { + test('발견한 사람이 보이고, 번호로 추가하면 친구 목록에 들어가며 나 자신은 빠짐', () async { + final lan = FakeLan(); + final hub = MemoryHub(); + final a = await AppDevice.create(hub, '010-1111-2222', pa, '가', lan: lan); + await AppDevice.create(hub, '010-2222-3333', pb, '나'); + lan.ctl.add(const LanPeer(phone: pa, host: '10.0.0.1', port: 1)); + lan.ctl.add(const LanPeer(phone: pb, host: '10.0.0.2', port: 2)); + final near = await a.app.friends.nearby(); + expect(near.map((e) => e.phone).toList(), [pb]); + expect(near.single.known, null); + final p = await a.app.friends.addNearby(pb); + expect(p!.name, pb); + expect((await a.app.friends.nearby()).single.known!.id, p.id); + }); + }); + + group('명함 공유', () { + test('회사 명함을 골라 공유하면 상대가 받고, 고치면 다시 보내기 전까지 상대 것은 그대로', () async { + final (a, b) = await pair(); + await befriend(a, b); + final bOfA = (await a.app.friends.people()).single; + final company = (await a.app.cards.mine()).firstWhere((c) => c.kind == 'company'); + await a.app.cards.edit(company.id, fields: {'name': '가', 'company': '한빛'}); + expect(await a.app.cards.shareWith(bOfA.id, [company.id]), 1); + expect(await a.app.cards.shareWith(bOfA.id, [company.id]), 0); // 이미 공유 중 + await AppDevice.settleAll(); + final aOfB = (await b.app.friends.people()).single; + var got = (await b.app.deps.storage.receivedCards.byPerson(aOfB.id)).firstWhere((c) => c.kind == 'company'); + expect(decodeFields(got.fieldsJson)['company'], '한빛'); + + await a.app.cards.edit(company.id, fields: {'name': '가', 'company': '새빛'}); + await AppDevice.settleAll(); + got = (await b.app.deps.storage.receivedCards.byPerson(aOfB.id)).firstWhere((c) => c.kind == 'company'); + expect(decodeFields(got.fieldsJson)['company'], '한빛'); // 아직 그대로 + final need = await a.app.cards.needingResend(); + expect(need.single.targetId, company.id); + expect(await a.app.cards.resend(need.single.id), true); + await AppDevice.settleAll(); + got = (await b.app.deps.storage.receivedCards.byPerson(aOfB.id)).firstWhere((c) => c.kind == 'company'); + expect(decodeFields(got.fieldsJson)['company'], '새빛'); + expect(await a.app.cards.needingResend(), isEmpty); + }); + + test('명함 만들기·순서 바꾸기·지우기', () async { + final (a, _) = await pair(); + final tpls = await a.app.layout.templates(); + final made = (await a.app.cards.createFromTemplate(tpls[1].id))!; + expect((await a.app.cards.mine()).length, 6); + await a.app.cards.move(made.id, 0); + expect((await a.app.cards.mine()).first.id, made.id); + await a.app.cards.delete(made.id); + expect((await a.app.cards.mine()).length, 5); + expect(await a.app.cards.createFromTemplate('없는틀'), null); + }); + }); + + group('대화', () { + test('개인 대화는 바로 되고, 주고받는 대화가 양쪽에 남음', () async { + final (a, b) = await pair(); + await befriend(a, b); + final bOfA = (await a.app.friends.people()).single; + final room = (await a.app.chats.openDirect(bOfA.id, 'personal'))!; + await a.app.chats.send(room.id, '안녕하세요'); + await AppDevice.settleAll(); + final aRooms = await b.app.chats.summaries(); + expect(aRooms.single.last!.body, '안녕하세요'); + await b.app.chats.send(aRooms.single.room.id, '반가워요'); + await AppDevice.settleAll(); + final mine = await a.app.chats.messages(room.id); + expect(mine.map((m) => m.body).toList(), ['안녕하세요', '반가워요']); + expect(mine.first.senderId, ChatService.me); + expect(await a.app.chats.openDirect(bOfA.id, 'personal'), isNotNull); + expect((await a.app.chats.summaries()).length, 1); // 같은 방을 다시 열어도 하나 + }); + + test('회사 대화는 회사 명함을 공유해야 열리고, 받는 쪽도 명함을 받아야 보임', () async { + final (a, b) = await pair(); + await befriend(a, b); + final bOfA = (await a.app.friends.people()).single; + expect(await a.app.chats.openDirect(bOfA.id, 'company'), null); + final company = (await a.app.cards.mine()).firstWhere((c) => c.kind == 'company'); + await a.app.cards.shareWith(bOfA.id, [company.id]); + final room = (await a.app.chats.openDirect(bOfA.id, 'company'))!; + await a.app.chats.send(room.id, '업무 이야기'); + await AppDevice.settleAll(); + final rooms = await b.app.chats.summaries(); + expect(rooms.single.room.contextKind, 'company'); + expect(rooms.single.last!.body, '업무 이야기'); + }); + + test('빈 메시지·없는 방은 보내지 않음', () async { + final (a, b) = await pair(); + await befriend(a, b); + final bOfA = (await a.app.friends.people()).single; + final room = (await a.app.chats.openDirect(bOfA.id, 'personal'))!; + expect(await a.app.chats.send(room.id, ' '), null); + expect(await a.app.chats.send('없는방', '안녕'), null); + }); + }); + + group('인맥', () { + test('같은 학교 명함을 주고받으면 동창으로 추측하고, 확정하면 추천에서 빠짐', () async { + final (a, b) = await pair(); + await befriend(a, b); + final bOfA = (await a.app.friends.people()).single; + final aOfB = (await b.app.friends.people()).single; + for (final d in [a, b]) { + final school = (await d.app.cards.mine()).firstWhere((c) => c.kind == 'school'); + await d.app.cards.edit(school.id, fields: {'name': d.name, 'school': '가람초', 'year': '2007'}); + } + final aSchool = (await a.app.cards.mine()).firstWhere((c) => c.kind == 'school'); + await a.app.cards.shareWith(bOfA.id, [aSchool.id]); + await AppDevice.settleAll(); + final guesses = await b.app.network.guessesFor(aOfB.id); + expect(guesses.first.relation, '동창'); + final sug = await b.app.network.suggestions(); + expect(sug.single.person.id, aOfB.id); + await b.app.network.addLink(aOfB.id, guesses.first.relation, guesses.first.detail); + expect(await b.app.network.suggestions(), isEmpty); + final cl = await b.app.network.clusters('school'); + expect(cl.single.people.single.id, aOfB.id); + expect((await b.app.network.links(aOfB.id)).single.confirmed, true); + final recv = await b.app.network.receivedCards(aOfB.id); + expect(recv.map((c) => c.kind).toSet(), {'personal', 'school'}); + await b.app.network.deleteReceivedCard(recv.first.id); + expect((await b.app.network.receivedCards(aOfB.id)).length, 1); + }); + + test('가족 명함을 받으면 가족 목록에 나옴', () async { + final (a, b) = await pair(); + await befriend(a, b); + final bOfA = (await a.app.friends.people()).single; + final fam = (await a.app.cards.mine()).firstWhere((c) => c.kind == 'family'); + await a.app.cards.edit(fam.id, fields: {'name': '가', 'relation': '형'}); + await a.app.cards.shareWith(bOfA.id, [fam.id]); + await AppDevice.settleAll(); + final members = await b.app.network.family(); + expect(members.single.relation, '형'); + }); + }); + + group('설정·배치', () { + test('설정은 범위 안으로 맞춰지고, 복구 조각 수는 필요한 수보다 작아지지 않음', () async { + final (a, _) = await pair(); + var s = await a.app.settings.update(storageUsagePercent: 99, recoveryShares: 4, recoveryThreshold: 9); + expect(s.storageUsagePercent, 95); + expect(s.recoveryShares, 4); + expect(s.recoveryThreshold, 4); + s = await a.app.settings.update(recoveryShares: 2); + expect(s.recoveryThreshold, 2); + expect((await a.app.settings.load()).exchangeMode, 'request'); + }); + + test('홈 구성·명함 틀 편집이 저장되고 변경 알림이 감', () async { + final (a, _) = await pair(); + var n = 0; + a.app.changes.listen((_) => n++); + await a.app.layout.moveHome(0, 2); + await a.app.layout.removeHome('family'); + expect((await a.app.layout.home()).any((e) => e.id == 'family'), false); + await a.app.layout.resetHome(); + expect((await a.app.layout.home()).first.id, 'chats'); + expect(n >= 3, true); + }); + + test('대화가 1MB 를 넘으면 별도 저장을 권하고 범위를 나눌 수 있음', () async { + final (a, b) = await pair(); + await befriend(a, b); + final bOfA = (await a.app.friends.people()).single; + final room = (await a.app.chats.openDirect(bOfA.id, 'personal'))!; + for (var i = 0; i < 3; i++) { + await a.app.chats.send(room.id, 'x' * 400000); + } + expect((await a.app.settings.adviseRoom(room.id)).overText, true); + final span = (await a.app.settings.selectableSpan(room.id))!; + expect(await a.app.settings.archiveRange(room.id, span.$1, span.$1), 1); + expect((await a.app.settings.adviseRoom(room.id)).overText, false); + expect((await a.app.chats.messages(room.id)).length, 2); + }); + }); +} diff --git a/test/archive_advice_test.dart b/test/archive_advice_test.dart new file mode 100644 index 0000000..ed04b79 --- /dev/null +++ b/test/archive_advice_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +void main() { + const s = AppSettings(); // 기본: 텍스트 1MB, 저장 공간 80% + + test('기준 이하면 권하지 않음', () { + final a = adviseArchive(textBytes: 1024 * 1024, usedPercent: 79, settings: s); + expect(a.recommended, false); + }); + + test('텍스트가 1MB를 넘으면 권함', () { + final a = adviseArchive(textBytes: 1024 * 1024 + 1, usedPercent: 10, settings: s); + expect(a.overText, true); + expect(a.overDisk, false); + expect(a.recommended, true); + }); + + test('저장 공간이 80% 이상이면 권함', () { + final a = adviseArchive(textBytes: 10, usedPercent: 80, settings: s); + expect(a.overDisk, true); + expect(a.recommended, true); + }); + + test('설정을 바꾸면 기준도 바뀜', () { + const t = AppSettings(separateTextBytes: 100, storageUsagePercent: 95); + expect(adviseArchive(textBytes: 101, usedPercent: 90, settings: t).overText, true); + expect(adviseArchive(textBytes: 50, usedPercent: 90, settings: t).overDisk, false); + }); +} diff --git a/test/df_parser_test.dart b/test/df_parser_test.dart new file mode 100644 index 0000000..d78da78 --- /dev/null +++ b/test/df_parser_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/platform_android/src/df_parser.dart'; + +void main() { + test('안드로이드 df 출력에서 사용률을 읽는다', () { + const out = 'Filesystem 1K-blocks Used Available Use% Mounted on\n' + '/dev/block/dm-6 118000000 69000000 48000000 59% /data\n'; + expect(parseDfUsedPercent(out), 59); + }); + + test('열 이름이 다른 형태와 줄바꿈이 섞여도 읽는다', () { + expect(parseDfUsedPercent('Filesystem 1K-blocks Used Avail Capacity Mounted\ntmpfs 100 10 90 10% /x\n\n'), 10); + expect(parseDfUsedPercent('a b\nc 1 2 3 100% /y'), 100); + }); + + test('읽을 수 없으면 null', () { + expect(parseDfUsedPercent(''), null); + expect(parseDfUsedPercent('Filesystem only'), null); + expect(parseDfUsedPercent('h\nrow 1 2 3 xx% /z'), null); + expect(parseDfUsedPercent('h\nrow 1 2 3 150% /z'), null); + }); +} diff --git a/test/discovery_test.dart b/test/discovery_test.dart new file mode 100644 index 0000000..68fbe90 --- /dev/null +++ b/test/discovery_test.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/discovery/discovery.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; + +class _FakeLan implements LanDiscovery { + final StreamController ctl = StreamController.broadcast(sync: true); + String? advertised; + bool started = false; + @override + Future advertise({required String phone, required int port}) async => advertised = '$phone:$port'; + @override + Stream get peers => ctl.stream; + @override + Future start() async => started = true; + @override + Future stop() async => started = false; +} + +void main() { + group('QR', () { + test('담았다가 읽으면 같다 (한글·특수문자 포함)', () { + const p = QrPayload(phone: '+821011112222', name: '정민호 😀', publicKey: 'a+b/c=='); + final back = QrPayload.decode(p.encode())!; + expect(back.phone, p.phone); + expect(back.name, p.name); + expect(back.publicKey, p.publicKey); + }); + + test('우리 앱 QR 이 아니거나 깨졌으면 null', () { + expect(QrPayload.decode('https://example.com'), null); + expect(QrPayload.decode('fm1:@@@'), null); + expect(QrPayload.decode('fm1:'), null); + expect(QrPayload.decode(''), null); + }); + }); + + group('흔들기', () { + test('짧은 시간에 세 번 세게 흔들면 한 번 알림', () { + final d = ShakeDetector(); + var fired = 0; + var t = 0; + for (var i = 0; i < 3; i++) { + if (d.feed(25, t)) fired++; + t += 100; + if (d.feed(9.8, t)) fired++; + t += 100; + } + expect(fired, 1); + }); + + test('가만히 있거나 한 번만 움직이면 알리지 않음', () { + final d = ShakeDetector(); + for (var t = 0; t < 3000; t += 50) { + expect(d.feed(9.8, t), false); + } + expect(d.feed(30, 3100), false); + }); + + test('느리게 세 번이면 알리지 않고, 알린 뒤 바로 또 알리지 않음', () { + final d = ShakeDetector(); + var t = 0; + var fired = false; + for (var i = 0; i < 3; i++) { + fired = d.feed(25, t) || fired; + d.feed(9.8, t + 50); + t += 1500; + } + expect(fired, false); + final e = ShakeDetector(); + var n = 0; + for (var i = 0; i < 12; i++) { + if (e.feed(25, i * 100)) n++; + e.feed(9.8, i * 100 + 50); + } + expect(n, 1); + }); + }); + + group('같은 네트워크 주소록', () { + test('발견한 상대를 번호로 찾고, 나 자신은 제외, 주소가 바뀌면 다시 알림', () async { + final lan = _FakeLan(); + final dir = LanDirectory(lan); + final seen = []; + dir.seen.listen(seen.add); + await dir.start(myPhone: '+821011112222', myPort: 4000); + expect(lan.advertised, '+821011112222:4000'); + expect(lan.started, true); + lan.ctl.add(const LanPeer(phone: '+821011112222', host: '10.0.0.1', port: 4000)); + lan.ctl.add(const LanPeer(phone: '+821022223333', host: '10.0.0.2', port: 4001)); + lan.ctl.add(const LanPeer(phone: '+821022223333', host: '10.0.0.2', port: 4001)); + lan.ctl.add(const LanPeer(phone: '+821022223333', host: '10.0.0.9', port: 4001)); + expect(seen.length, 2); + expect(dir.find('+821022223333')!.host, '10.0.0.9'); + expect(dir.find('+821011112222'), null); + dir.forget('+821022223333'); + expect(dir.all, isEmpty); + await dir.stop(); + expect(lan.started, false); + }); + }); +} diff --git a/test/events_test.dart b/test/events_test.dart new file mode 100644 index 0000000..01a0271 --- /dev/null +++ b/test/events_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/events/events.dart'; + +void main() { + final today = DateTime(2026, 10, 26); + + group('일정 계산', () { + test('올해 지났으면 내년, 오늘이면 오늘', () { + const b = FamilyEvent(personId: 'a', kind: EventKind.birthday, month: 3, day: 5, year: 1960); + expect(b.nextFrom(today), DateTime(2027, 3, 5)); + const t = FamilyEvent(personId: 'a', kind: EventKind.birthday, month: 10, day: 26); + expect(t.nextFrom(today), DateTime(2026, 10, 26)); + }); + + test('2월 29일은 평년엔 28일', () { + const e = FamilyEvent(personId: 'a', kind: EventKind.birthday, month: 2, day: 29); + expect(e.occurrenceIn(2027), DateTime(2027, 2, 28)); + expect(e.occurrenceIn(2028), DateTime(2028, 2, 29)); + }); + + test('다가오는 일정은 가까운 순, 범위 밖은 제외, 나이·주기 계산', () { + final list = upcoming(const [ + FamilyEvent(personId: 'far', kind: EventKind.birthday, month: 1, day: 1), + FamilyEvent(personId: 'b', kind: EventKind.memorial, month: 11, day: 10, year: 2016), + FamilyEvent(personId: 'a', kind: EventKind.birthday, month: 10, day: 28, year: 1990), + ], today, withinDays: 30); + expect(list.map((e) => e.event.personId).toList(), ['a', 'b']); + expect(list[0].daysLeft, 2); + expect(list[0].years, 36); + expect(list[1].years, 10); + expect(dayWord(0), '오늘'); + expect(dayWord(1), '내일'); + expect(dayWord(5), '5일 후'); + }); + + test('라벨', () { + const m = FamilyEvent(personId: 'a', kind: EventKind.memorial, month: 1, day: 1); + expect(m.label('할아버지'), '할아버지 기일'); + }); + }); + + group('알림 일정', () { + test('설정 글자 해석: 1일 전, 2일 전, 중복·잘못된 칸 정리', () { + expect(parseLeadDays('1일 전, 2일 전'), [2, 1]); + expect(parseLeadDays('7, 1, 1, x, 0'), [7, 1, 0]); + expect(parseLeadDays(null), isEmpty); + expect(leadDaysText([7, 1]), '7, 1'); + }); + + test('이미 지난 알림은 빼고 시각순', () { + const e = FamilyEvent(personId: 'a', kind: EventKind.birthday, month: 10, day: 28); + final now = DateTime(2026, 10, 26, 10); // 9시는 지남 + final plans = planReminders([e], now, [2, 1, 0], (_) => '엄마'); + // 2일 전(10/26 09:00)은 지남 → 1일 전, 당일만 + expect(plans.map((p) => p.leadDays).toList(), [1, 0]); + expect(plans.first.fireAt, DateTime(2026, 10, 27, 9)); + expect(plans.first.text, '내일 엄마 생일입니다'); + expect(plans.last.text, '오늘 엄마 생일입니다'); + }); + + test('너무 먼 일정은 제외', () { + const e = FamilyEvent(personId: 'a', kind: EventKind.birthday, month: 6, day: 1); + expect(planReminders([e], today, [1], (_) => 'x'), isEmpty); + }); + }); + + group('통화 요약', () { + test('기록 없음', () { + expect(summarizeCalls(const []).total, 0); + }); + + test('횟수·부재중·총 시간·마지막 통화·자주 받는 시간대', () { + final s = summarizeCalls([ + CallRecord(DateTime(2026, 10, 1, 19, 5), 600, CallDirection.outgoing), + CallRecord(DateTime(2026, 10, 8, 19, 40), 300, CallDirection.incoming), + CallRecord(DateTime(2026, 10, 15, 8, 10), 60, CallDirection.outgoing), + CallRecord(DateTime(2026, 10, 20, 14, 0), 0, CallDirection.missed), + ]); + expect(s.total, 4); + expect(s.answered, 3); + expect(s.missed, 1); + expect(s.totalSeconds, 960); + expect(s.averageSeconds, 320); + expect(s.lastAt, DateTime(2026, 10, 20, 14, 0)); + expect(s.lastDirection, CallDirection.missed); + expect(s.windows.first.fromHour, 19); + expect(s.windows.first.label, '오후 7시 ~ 오후 8시'); + expect(s.windows.length, 2); + expect(durationText(45), '45초'); + expect(durationText(600), '10분'); + expect(durationText(4000), '1시간 6분'); + }); + }); +} diff --git a/test/family_gathering_test.dart b/test/family_gathering_test.dart new file mode 100644 index 0000000..877aa96 --- /dev/null +++ b/test/family_gathering_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/events/events.dart'; +import 'package:friendly_map/modules/kinship/kinship.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'support/app_device.dart'; + +class FakeLog implements CallLogProvider { + FakeLog(this.entries); + final List entries; + @override + Future> forNumber(String number, {int limit = 200}) async => entries; +} + +class FakePhone implements PhoneActions { + final dialed = []; + final texted = []; + @override + Future dial(String number) async { + dialed.add(number); + return true; + } + @override + Future sms(String number) async { + texted.add(number); + return true; + } +} + +class FakeNotifier implements Notifier { + List last = []; + @override + Future replaceAll(List notices) async => last = notices; +} + +void main() { + late AppDevice me; + late FakeNotifier notifier; + late FakePhone phone; + + setUp(() async { + notifier = FakeNotifier(); + phone = FakePhone(); + me = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '나', + notifier: notifier, + phone: phone, + callLog: FakeLog([ + CallLogEntry('+821033334444', DateTime(2026, 10, 1, 19, 10), 600, CallKind.outgoing), + CallLogEntry('+821033334444', DateTime(2026, 10, 9, 19, 30), 120, CallKind.incoming), + CallLogEntry('+821033334444', DateTime(2026, 10, 12, 9, 0), 0, CallKind.missed), + ])); + }); + + group('촌수 알려주기', () { + test('번호 없는 친척도 가계도에 넣고, 나와 내 자식 눈으로 본 관계를 알려 준다', () async { + final k = me.app.kin; + await k.setMe(gender: Gender.male, birthYear: 1990); + final father = (await k.addRelative('아버지', gender: Gender.male, birthYear: 1960))!; + final uncle = (await k.addRelative('큰아버지', gender: Gender.male, birthYear: 1955))!; + final cousin = (await k.addRelative('사촌형', gender: Gender.male, birthYear: 1985))!; + final gf = (await k.addRelative('할아버지', gender: Gender.male, birthYear: 1930))!; + final son = (await k.addRelative('철수', gender: Gender.male, birthYear: 2020))!; + expect(await k.setParent('me', father.id), true); + expect(await k.setParent(father.id, gf.id), true); + expect(await k.setParent(uncle.id, gf.id), true); + expect(await k.setParent(cousin.id, uncle.id), true); + expect(await k.setParent(son.id, 'me'), true); + + final r = await k.relationOf(cousin.id); + expect(r.title, '친사촌 형'); + expect(r.chon, 4); + + final views = await k.explain(cousin.id); + expect(views.map((v) => v.whose).toList(), ['나', '철수']); + expect(views[1].relation.title, '당숙'); + expect(views[1].relation.chon, 5); + + final list = await k.everyone(); + final chons = list.map((e) => e.relation.chon ?? 99).toList(); + expect(chons.first, 1); // 가까운 촌수가 맨 앞 + expect(chons, [...chons]..sort()); + }); + + test('잘못된 번호는 거절, 같은 번호는 한 번만', () async { + expect(await me.app.kin.addRelative('x', phone: '12'), isNull); + expect(await me.app.kin.addRelative(' '), isNull); + expect(await me.app.kin.addRelative('고모', phone: '010-5555-6666'), isNotNull); + expect(await me.app.kin.addRelative('고모2', phone: '01055556666'), isNull); + }); + + test('다시 열어도 가계도가 남아 있다', () async { + final f = (await me.app.kin.addRelative('엄마', gender: Gender.female))!; + await me.app.kin.setParent('me', f.id); + expect((await me.app.kin.relationOf(f.id)).title, '어머니'); + expect(await me.app.kin.inTree(f.id), true); + await me.app.kin.exclude(f.id); + expect((await me.app.kin.relationOf(f.id)).related, false); + }); + }); + + group('사람 정보와 통화', () { + test('통화 요약: 횟수·시간·자주 받는 시간대, 바로 전화·문자', () async { + final p = (await me.app.friends.add('+821033334444', '삼촌'))!; + final s = await me.app.calls.summary(p.id); + expect(s.total, 3); + expect(s.missed, 1); + expect(s.totalSeconds, 720); + expect(s.windows.first.fromHour, 19); + expect(await me.app.calls.call(p.id), true); + expect(await me.app.calls.sms(p.id), true); + expect(phone.dialed, ['+821033334444']); + expect(phone.texted, ['+821033334444']); + }); + + test('번호 없는 친척은 걸 수 없고 요약도 비어 있다', () async { + final r = (await me.app.kin.addRelative('먼 친척'))!; + expect(await me.app.calls.reachable(r.id), false); + expect(await me.app.calls.call(r.id), false); + expect((await me.app.calls.summary(r.id)).total, 0); + }); + + test('생일·돌아가신 날 기록, 주제·메모', () async { + final r = (await me.app.kin.addRelative('할머니'))!; + await me.app.facts.save(r.id, const PersonFacts(birthMonth: 5, birthDay: 3, topics: ['안부', '제사'], memo: '저녁에 편함')); + var f = await me.app.facts.get(r.id); + expect(f.hasBirthday, true); + expect(f.deceased, false); + expect(f.topics, ['안부', '제사']); + await me.app.facts.setDeceased(r.id, year: 2020, month: 2, day: 14); + f = await me.app.facts.get(r.id); + expect(f.deceased, true); + expect(f.deceasedText, '2020.2.14'); + expect(f.topics, ['안부', '제사']); // 다른 값은 그대로 + await me.app.facts.setDeceased(r.id); // 해제 + expect((await me.app.facts.get(r.id)).deceased, false); + expect(() => me.app.facts.save(r.id, const PersonFacts(birthMonth: 2, birthDay: 30)), throwsArgumentError); + expect(() => me.app.facts.save(r.id, const PersonFacts(birthMonth: 2)), throwsArgumentError); + }); + }); + + group('일정 알림', () { + test('기본은 하루 전과 당일, 설정에 적은 날짜대로 예약이 바뀐다', () async { + final r = (await me.app.kin.addRelative('엄마'))!; + final soon = DateTime.now().add(const Duration(days: 10)); + await me.app.facts.save(r.id, PersonFacts(birthMonth: soon.month, birthDay: soon.day)); + expect(await me.app.events.leadDays(), [1, 0]); + expect(await me.app.events.reschedule(), 2); + expect(notifier.last.length, 2); + expect(notifier.last.first.text, '내일 엄마 생일입니다'); + + await me.app.events.setLeadDays('1일 전, 2일 전, 7일 전'); + expect(await me.app.events.leadDays(), [7, 2, 1]); + expect(notifier.last.map((n) => n.text).toList(), + ['7일 뒤 엄마 생일입니다', '2일 뒤 엄마 생일입니다', '내일 엄마 생일입니다']); + }); + + test('돌아가신 분은 생일 대신 기일, 표시 설정', () async { + final r = (await me.app.kin.addRelative('할아버지'))!; + final soon = DateTime.now().add(const Duration(days: 3)); + await me.app.facts.save( + r.id, PersonFacts(birthMonth: 1, birthDay: 1, deceasedYear: 2015, deceasedMonth: soon.month, deceasedDay: soon.day)); + final list = await me.app.events.forPerson(r.id); + expect(list.length, 1); + expect(list.first.upcoming.event.kind, EventKind.memorial); + expect(list.first.text, '할아버지 기일'); + expect(list.first.upcoming.years, soon.year - 2015); + expect((await me.app.events.soon(withinDays: 5)).length, 1); + expect(await me.app.events.showDeceased(), true); + await me.app.events.setShowDeceased(false); + expect(await me.app.events.showDeceased(), false); + }); + + test('음력 기일: 음력 날짜가 양력으로 바뀌어 알림이 잡힌다', () async { + final r = (await me.app.kin.addRelative('할아버지'))!; + final target = DateTime.now().add(const Duration(days: 20)); + final l = solarToLunar(target)!; + await me.app.facts.setDeceased(r.id, year: 2015, month: l.month, day: l.day, lunar: true); + final list = await me.app.events.forPerson(r.id, withinDays: 40); + expect(list.length, 1); + final d = list.first.upcoming.date; + expect(DateTime(d.year, d.month, d.day).difference(DateTime(target.year, target.month, target.day)).inDays.abs() <= 1, true); + expect(list.first.text, '할아버지 기일(음력)'); + expect((await me.app.facts.get(r.id)).deceasedText.startsWith('음력 2015.'), true); + expect(() => me.app.facts.save(r.id, const PersonFacts(birthMonth: 13, birthDay: 1, birthLunar: true)), throwsArgumentError); + }); + + test('알림 부품이 없으면 예약 0건', () async { + final bare = await AppDevice.create(MemoryHub(), '010-7777-8888', '+821077778888', '무'); + expect(bare.app.events.canNotify, false); + expect(await bare.app.events.reschedule(), 0); + }); + }); +} diff --git a/test/inference_test.dart b/test/inference_test.dart new file mode 100644 index 0000000..93d8d6f --- /dev/null +++ b/test/inference_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/inference/inference.dart'; + +CardSnapshot school(String name, String year) => CardSnapshot('school', {'school': name, 'year': year}); +CardSnapshot company(String name, {bool former = false}) => + CardSnapshot('company', {'company': name, if (former) 'former': 'true'}); + +void main() { + const inf = Inferer(); + + test('이름을 다듬어 비교 (초등학교=초, 띄어쓰기, (주))', () { + expect(Inferer.normalize('가람 초등학교'), Inferer.normalize('가람초')); + expect(Inferer.normalize('(주)한빛소프트'), Inferer.normalize('한빛소프트')); + expect(Inferer.normalize('한국대학교') == Inferer.normalize('서울대학교'), false); + }); + + test('같은 학교, 같은 연도면 동창', () { + final g = inf.infer(mine: [school('가람초', '2007')], theirs: [school('가람 초등학교', '2007')]); + expect(g.first.relation, '동창'); + expect(g.first.confidence, 95); + }); + + test('같은 학교, 다른 연도면 동문, 연도를 모르면 낮은 신뢰도', () { + final a = inf.infer(mine: [school('한국대', '2008')], theirs: [school('한국대학교', '2012')]); + expect(a.single.relation, '동문'); + expect(a.single.confidence, 70); + final b = inf.infer(mine: [CardSnapshot('school', {'school': '한국대'})], theirs: [school('한국대', '2012')]); + expect(b.single.confidence, 60); + }); + + test('같은 회사는 직장 동료, 한쪽이 퇴사했으면 전 직장 동료', () { + expect(inf.infer(mine: [company('한빛소프트')], theirs: [company('(주)한빛소프트')]).single.relation, '직장 동료'); + final former = inf.infer(mine: [company('한빛', former: true)], theirs: [company('한빛')]); + expect(former.single.relation, '전 직장 동료'); + }); + + test('같은 모임·같은 여행, 가족 명함', () { + final g = inf.infer( + mine: [CardSnapshot('group', {'group': '북한산 산악회', 'trip': '2024 제주'})], + theirs: [ + CardSnapshot('group', {'group': '북한산산악회', 'trip': '2024 제주'}), + CardSnapshot('family', {'relation': '이모'}), + ], + ); + expect(g.map((e) => e.relation).toSet(), {'같은 모임', '같은 여행', '가족'}); + expect(g.first.relation, '가족'); + }); + + test('겹치는 게 없으면 빈 목록, 신뢰도 순서, 같은 근거는 한 번만', () { + expect(inf.infer(mine: [school('가람초', '2007')], theirs: [company('한빛')]), isEmpty); + final g = inf.infer( + mine: [school('가람초', '2007'), company('한빛')], + theirs: [school('가람초', '2007'), school('가람초', '2007'), company('한빛')], + ); + expect(g.length, 2); + expect(g.first.confidence >= g.last.confidence, true); + }); + + test('추천: 사람마다 가장 그럴듯한 하나, 기준보다 낮으면 빠짐', () { + final r = inf.recommend( + mine: [school('가람초', '2007'), company('한빛')], + others: { + 'p1': [school('가람초', '2007')], + 'p2': [company('한빛')], + 'p3': [CardSnapshot('school', {'school': '가람초'})].map((c) => c).toList(), + 'p4': [company('다른회사')], + }, + ); + expect(r.map((e) => e.personId).toList(), ['p1', 'p2', 'p3']); + expect(r.first.guess.relation, '동창'); + expect(inf.recommend(mine: [school('가람초', '2007')], others: {'p': [CardSnapshot('school', {'school': '가람초'})]}, minConfidence: 80), isEmpty); + }); +} diff --git a/test/keys_crypto_test.dart b/test/keys_crypto_test.dart new file mode 100644 index 0000000..31437d1 --- /dev/null +++ b/test/keys_crypto_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/keys/keys.dart'; + +Future> deriverFor(int seed) async => List.generate(32, (i) => (i + seed) % 256); + +Future keysOf(int seed) async => IdentityKeys((label) async { + final base = await deriverFor(seed); + return [...base.take(31), label.length % 256]; + }); + +void main() { + test('같은 기본 비밀이면 같은 공개 키, 다르면 다른 공개 키', () async { + final a1 = LocalIdentityProvider(await keysOf(1)); + final a2 = LocalIdentityProvider(await keysOf(1)); + final b = LocalIdentityProvider(await keysOf(2)); + expect(await a1.myPublicKey(), await a2.myPublicKey()); + expect(await a1.myPublicKey() == await b.myPublicKey(), false); + }); + + test('둘만 아는 키: 서로 잠근 것을 서로 풀 수 있다', () async { + final ka = await keysOf(1), kb = await keysOf(2); + final pubA = await LocalIdentityProvider(ka).myPublicKey(); + final pubB = await LocalIdentityProvider(kb).myPublicKey(); + final a = SharedPayloadCipher(ka), b = SharedPayloadCipher(kb); + final sealed = await a.seal(plain: '안녕 😀', peerPublicKey: pubB, context: 'room1'); + expect(sealed.contains('안녕'), false); + expect(await b.open(sealed: sealed, peerPublicKey: pubA, context: 'room1'), '안녕 😀'); + final back = await b.seal(plain: '답', peerPublicKey: pubA, context: 'room1'); + expect(await a.open(sealed: back, peerPublicKey: pubB, context: 'room1'), '답'); + }); + + test('단위(대화방)가 다르면 풀리지 않는다', () async { + final ka = await keysOf(1), kb = await keysOf(2); + final pubA = await LocalIdentityProvider(ka).myPublicKey(); + final pubB = await LocalIdentityProvider(kb).myPublicKey(); + final sealed = await SharedPayloadCipher(ka).seal(plain: 'x', peerPublicKey: pubB, context: 'room1'); + expect(await SharedPayloadCipher(kb).open(sealed: sealed, peerPublicKey: pubA, context: 'room2'), null); + }); + + test('제3자는 풀지 못하고, 중간에 바뀐 내용은 거절', () async { + final ka = await keysOf(1), kb = await keysOf(2), kc = await keysOf(3); + final pubA = await LocalIdentityProvider(ka).myPublicKey(); + final pubB = await LocalIdentityProvider(kb).myPublicKey(); + final sealed = await SharedPayloadCipher(ka).seal(plain: 'x', peerPublicKey: pubB, context: 'r'); + expect(await SharedPayloadCipher(kc).open(sealed: sealed, peerPublicKey: pubA, context: 'r'), null); + final tampered = '${sealed.substring(0, sealed.length - 4)}AAAA'; + expect(await SharedPayloadCipher(kb).open(sealed: tampered, peerPublicKey: pubA, context: 'r'), null); + expect(await SharedPayloadCipher(kb).open(sealed: 'not base64!!', peerPublicKey: pubA, context: 'r'), null); + }); +} diff --git a/test/keys_test.dart b/test/keys_test.dart new file mode 100644 index 0000000..5ef9fbf --- /dev/null +++ b/test/keys_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/keys/keys.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; + +class _MemoryStore implements SecureKeyStore { + final Map _m = {}; + @override + Future read(String name) async => _m[name]; + @override + Future write(String name, String value) async => _m[name] = value; + @override + Future delete(String name) async => _m.remove(name); +} + +void main() { + test('처음에는 키가 없고, 만들면 12단어가 나온다', () async { + final p = LocalKeyProvider(_MemoryStore()); + expect(await p.isSetUp(), false); + final phrase = await p.setUpNew(); + expect(phrase.split(' ').length, 12); + expect(isValidRecoveryPhrase(phrase), true); + expect(await p.isSetUp(), true); + expect(await p.showPhrase(), phrase); + }); + + test('키를 만들기 전에는 저장소 암호를 줄 수 없다', () async { + final p = LocalKeyProvider(_MemoryStore()); + expect(() => p.databasePassword(), throwsStateError); + }); + + test('같은 복구 코드로 복구하면 같은 저장소 암호가 나온다', () async { + final a = LocalKeyProvider(_MemoryStore()); + final phrase = await a.setUpNew(); + final pwA = await a.databasePassword(); + + final b = LocalKeyProvider(_MemoryStore()); + expect(await b.restore(phrase), true); + expect(await b.databasePassword(), pwA); + }); + + test('다른 코드면 다른 암호, 같은 기기에서는 항상 같은 암호', () async { + final a = LocalKeyProvider(_MemoryStore()); + await a.setUpNew(); + final b = LocalKeyProvider(_MemoryStore()); + await b.setUpNew(); + expect(await a.databasePassword(), await a.databasePassword()); + expect(await a.databasePassword() == await b.databasePassword(), false); + expect((await a.databasePassword()).length, 64); + }); + + test('잘못된 코드는 복구되지 않는다', () async { + final p = LocalKeyProvider(_MemoryStore()); + expect(await p.restore('abc def'), false); + expect(await p.isSetUp(), false); + }); + + test('공백과 대소문자가 달라도 같은 코드로 본다', () async { + final a = LocalKeyProvider(_MemoryStore()); + final phrase = await a.setUpNew(); + final messy = ' ${phrase.toUpperCase().replaceAll(' ', ' ')} '; + final b = LocalKeyProvider(_MemoryStore()); + expect(await b.restore(messy), true); + expect(await b.databasePassword(), await a.databasePassword()); + }); + + test('용도별 값은 서로 다르고, 복구해도 같은 값이 나온다', () async { + final a = LocalKeyProvider(_MemoryStore()); + final phrase = await a.setUpNew(); + expect(await a.derive('x') == await a.derive('y'), false); + expect(await a.mailboxToken() == await a.databasePassword(), false); + final b = LocalKeyProvider(_MemoryStore()); + await b.restore(phrase); + expect(await b.derive('x'), await a.derive('x')); + expect(await b.mailboxToken(), await a.mailboxToken()); + }); + + test('복구 코드로 새 기기를 만들어도 같은 신원 키가 나온다', () async { + final a = LocalKeyProvider(_MemoryStore()); + final phrase = await a.setUpNew(); + final b = LocalKeyProvider(_MemoryStore()); + await b.restore(phrase); + final ka = await LocalIdentityProvider(IdentityKeys(a.derive)).myPublicKey(); + final kb = await LocalIdentityProvider(IdentityKeys(b.derive)).myPublicKey(); + expect(ka, kb); + }); +} diff --git a/test/kinship_test.dart b/test/kinship_test.dart new file mode 100644 index 0000000..d695599 --- /dev/null +++ b/test/kinship_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/kinship/kinship.dart'; + +const M = Gender.male, F = Gender.female; + +/// 시험용 가족. 나(남, 1990)와 아내, 아들, 양가 친척. +KinGraph family() { + final g = KinGraph(); + void p(String id, Gender gen, int year) => g.put(KinPerson(id, gender: gen, birthYear: year)); + p('me', M, 1990); + p('father', M, 1960); + p('mother', F, 1962); + p('gf', M, 1930); // 친할아버지 + p('gm', F, 1932); // 친할머니 + p('mgm', F, 1935); // 외할머니 + p('bigUncle', M, 1955); // 아버지의 형 + p('bigAunt', F, 1957); // 큰아버지의 아내 + p('aunt', F, 1965); // 아버지의 여동생(고모) + p('mUncle', M, 1964); // 어머니의 남동생(외삼촌) + p('mAunt', F, 1966); // 이모 + p('cousinElder', M, 1985); // 큰아버지의 아들 + p('cousinGojong', F, 1992); // 고모의 딸 + p('cousinOe', M, 1995); // 외삼촌의 아들 + p('sister', F, 1993); + p('wife', F, 1991); + p('wifeFather', M, 1962); + p('wifeSister', F, 1994); + p('son', M, 2020); + p('daughterInLaw', F, 1990); + p('grandson', M, 2045); + for (final c in ['father', 'bigUncle', 'aunt']) { + g.addParent(c, 'gf'); + } + g.addParent('father', 'gm'); + g.addParent('bigUncle', 'gm'); + g.addParent('aunt', 'gm'); + g.addParent('mother', 'mgm'); + g.addParent('mUncle', 'mgm'); + g.addParent('mAunt', 'mgm'); + g.addParent('me', 'father'); + g.addParent('me', 'mother'); + g.addParent('sister', 'father'); + g.addParent('sister', 'mother'); + g.addParent('cousinElder', 'bigUncle'); + g.addParent('cousinGojong', 'aunt'); + g.addParent('cousinOe', 'mUncle'); + g.addParent('wife', 'wifeFather'); + g.addParent('wifeSister', 'wifeFather'); + g.addParent('son', 'me'); + g.addParent('son', 'wife'); + g.addParent('grandson', 'son'); + g.addSpouse('me', 'wife'); + g.addSpouse('father', 'mother'); + g.addSpouse('gf', 'gm'); + g.addSpouse('bigUncle', 'bigAunt'); + g.addSpouse('son', 'daughterInLaw'); + return g; +} + +String t(KinGraph g, String a, String b) => relate(g, a, b).title; +int? c(KinGraph g, String a, String b) => relate(g, a, b).chon; + +void main() { + final g = family(); + + group('직계', () { + test('부모·자녀 1촌, 조부모·손주 2촌', () { + expect((t(g, 'me', 'father'), c(g, 'me', 'father')), ('아버지', 1)); + expect((t(g, 'me', 'mother'), c(g, 'me', 'mother')), ('어머니', 1)); + expect((t(g, 'me', 'son'), c(g, 'me', 'son')), ('아들', 1)); + expect((t(g, 'me', 'gf'), c(g, 'me', 'gf')), ('친할아버지', 2)); + expect(t(g, 'me', 'mgm'), '외할머니'); + expect((t(g, 'me', 'grandson'), c(g, 'me', 'grandson')), ('손자', 2)); + }); + + test('거꾸로도 맞다: 아들 입장에서 나는 아버지', () { + expect(t(g, 'son', 'me'), '아버지'); + expect(t(g, 'grandson', 'gf'), '고조할아버지'); // 손자 → 아들 → 나 → 아버지 → 할아버지: 4대 위 + expect(t(g, 'son', 'gf'), '증조할아버지'); + }); + }); + + group('형제와 방계', () { + test('형제자매는 2촌, 나이로 형·동생 구분', () { + expect((t(g, 'me', 'sister'), c(g, 'me', 'sister')), ('여동생', 2)); + expect(t(g, 'sister', 'me'), '오빠'); + }); + + test('아버지 쪽 삼촌: 큰아버지·고모, 어머니 쪽: 외삼촌·이모 (모두 3촌)', () { + expect((t(g, 'me', 'bigUncle'), c(g, 'me', 'bigUncle')), ('큰아버지', 3)); + expect(t(g, 'me', 'aunt'), '고모'); + expect(t(g, 'me', 'mUncle'), '외삼촌'); + expect(t(g, 'me', 'mAunt'), '이모'); + }); + + test('사촌은 4촌: 친사촌·고종사촌·외사촌, 나이에 따라 형·동생', () { + expect((t(g, 'me', 'cousinElder'), c(g, 'me', 'cousinElder')), ('친사촌 형', 4)); + expect(t(g, 'me', 'cousinGojong'), '고종사촌 여동생'); + expect(t(g, 'me', 'cousinOe'), '외사촌 남동생'); + }); + + test('내 아들 입장: 아버지의 사촌은 당숙(5촌), 큰아버지는 4촌 할아버지 항렬이 아니라 5촌이 아님을 구분', () { + expect((t(g, 'son', 'cousinElder'), c(g, 'son', 'cousinElder')), ('당숙', 5)); + expect(c(g, 'son', 'bigUncle'), 4); + expect(c(g, 'son', 'sister'), 3); // 고모 + expect(t(g, 'son', 'sister'), '고모'); + }); + + test('조카: 나의 형제의 자녀는 3촌', () { + final g2 = family(); + g2.put(const KinPerson('nephew', gender: Gender.male, birthYear: 2018)); + g2.addParent('nephew', 'sister'); + expect((t(g2, 'me', 'nephew'), c(g2, 'me', 'nephew')), ('조카', 3)); + }); + }); + + group('인척', () { + test('배우자 0촌, 며느리·사위 1촌', () { + expect((t(g, 'me', 'wife'), c(g, 'me', 'wife')), ('아내', 0)); + expect(t(g, 'wife', 'me'), '남편'); + expect((t(g, 'me', 'daughterInLaw'), c(g, 'me', 'daughterInLaw')), ('며느리', 1)); + }); + + test('아내의 부모는 장인, 아내의 동생은 처제', () { + expect((t(g, 'me', 'wifeFather'), c(g, 'me', 'wifeFather')), ('장인', 1)); + expect(t(g, 'me', 'wifeSister'), '처제'); + expect(t(g, 'wife', 'father'), '시아버지'); + }); + + test('큰아버지의 아내는 큰어머니(3촌)', () { + expect((t(g, 'me', 'bigAunt'), c(g, 'me', 'bigAunt')), ('큰어머니', 3)); + }); + + test('모르는 사이는 none', () { + g.put(const KinPerson('stranger')); + final r = relate(g, 'me', 'stranger'); + expect(r.related, false); + expect(r.chon, null); + }); + }); + + group('이름표·가계도 관리', () { + test('label 은 촌수까지 읽기 좋게', () { + expect(relate(g, 'me', 'bigUncle').label, '큰아버지 (3촌)'); + expect(relate(g, 'me', 'wife').label, '아내'); + expect(relate(g, 'me', 'me').label, '본인'); + }); + + test('나이를 모르면 형·동생 대신 형제', () { + final g3 = KinGraph(); + g3.put(const KinPerson('me', gender: M)); + g3.put(const KinPerson('x', gender: M)); + g3.put(const KinPerson('p', gender: M)); + g3.addParent('me', 'p'); + g3.addParent('x', 'p'); + expect(t(g3, 'me', 'x'), '형제'); + }); + + test('부모 순환·셋째 부모는 거절', () { + final g4 = KinGraph(); + for (final id in ['a', 'b', 'c', 'd']) { + g4.put(KinPerson(id)); + } + expect(g4.addParent('a', 'b'), true); + expect(g4.addParent('b', 'a'), false); // 순환 + expect(g4.addParent('a', 'c'), true); + expect(g4.addParent('a', 'd'), false); // 부모는 최대 둘 + expect(g4.addParent('a', 'a'), false); + }); + + test('글자로 저장했다 복원하면 같은 계산, 깨진 글자는 빈 가계도', () { + final back = KinGraph.fromJson(g.toJson()); + expect(t(back, 'me', 'bigUncle'), '큰아버지'); + expect(t(back, 'me', 'wifeFather'), '장인'); + expect(KinGraph.fromJson('{깨짐').people, isEmpty); + expect(KinGraph.fromJson(null).people, isEmpty); + }); + + test('사람을 지우면 연결도 사라진다', () { + final g5 = KinGraph.fromJson(g.toJson()); + g5.removePerson('father'); + expect(g5.has('father'), false); + expect(g5.parentsOf('me').contains('father'), false); + expect(relate(g5, 'me', 'bigUncle').related, false); + }); + }); +} diff --git a/test/layout_test.dart b/test/layout_test.dart new file mode 100644 index 0000000..b55602b --- /dev/null +++ b/test/layout_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/layout/layout.dart'; + +void main() { + group('홈 구성', () { + test('처음엔 기본 구성, 바꾸면 저장되어 다시 읽어도 같다', () async { + final store = MemoryTextStore(); + final home = homeLayoutStore(store); + expect((await home.load()).first.id, 'chats'); + await home.move(0, 3); + await home.remove('family'); + final items = await home.load(); + expect(items.map((e) => e.id).take(3).toList(), ['network', 'gathering', 'chats']); + expect(items.any((e) => e.id == 'family'), false); + expect((await homeLayoutStore(store).load()).length, items.length); + }); + + test('업데이트로 생긴 기본 항목은 기존 사용자에게도 한 번만 끼워 주고, 지운 항목은 되살리지 않는다', () async { + final store = MemoryTextStore(); + // 옛 버전이 저장한 홈: 가족 모임이 없고 학교를 지운 상태 + final old = ['chats', 'network', 'family', 'company', 'group', 'location', 'discover', 'cards']; + await store.write('layout_home', '[${old.map((i) => '{"id":"$i","title":"$i","visible":true}').join(',')}]'); + final items = await homeLayoutStore(store).load(); + final ids = items.map((e) => e.id).toList(); + expect(ids.indexOf('gathering'), ids.indexOf('family') + 1); + expect(ids.contains('school'), false); + // 사용자가 새 항목을 지우면 다시 나타나지 않는다 + await homeLayoutStore(store).remove('gathering'); + expect((await homeLayoutStore(store).load()).any((e) => e.id == 'gathering'), false); + }); + + test('숨기기·이름 바꾸기는 upsert 로, 끝 밖으로 옮겨도 안전', () async { + final home = homeLayoutStore(MemoryTextStore()); + final first = (await home.load()).first; + await home.upsert(first.copyWith(title: '대화', visible: false)); + final again = (await home.load()).first; + expect(again.title, '대화'); + expect(again.visible, false); + await home.move(0, 999); + expect((await home.load()).last.id, 'chats'); + await home.move(99, 0); + expect((await home.load()).length, defaultHomeItems().length); + }); + + test('처음으로 되돌리기, 깨진 저장은 기본으로', () async { + final store = MemoryTextStore(); + final home = homeLayoutStore(store); + await home.remove('chats'); + await home.reset(); + expect((await home.load()).first.id, 'chats'); + store.data['layout_home'] = '{깨짐'; + expect((await home.load()).length, defaultHomeItems().length); + }); + }); + + group('명함 틀', () { + test('종류마다 기본 틀이 있고 항목이 있다', () { + final t = defaultCardTemplates(); + expect(t.map((e) => e.kind).toSet(), {'personal', 'company', 'school', 'group', 'family'}); + expect(t.every((e) => e.fields.isNotEmpty), true); + }); + + test('알려진 항목은 중복 없이 모든 기본 틀의 항목을 포함', () { + final known = {for (final f in knownFieldDefs()) f.key}; + expect(known.length, knownFieldDefs().length); + for (final t in defaultCardTemplates()) { + for (final f in t.fields) { + expect(known.contains(f.key), true); + } + } + }); + + test('항목을 고치고 새 틀을 만들고 지운다', () async { + final tpl = cardTemplateStore(MemoryTextStore()); + final company = (await tpl.load()).firstWhere((e) => e.kind == 'company'); + await tpl.upsert(company.copyWith(fields: [...company.fields, const FieldDef('dept', '부서')])); + await tpl.upsert(const CardTemplate(id: 'mine', kind: 'group', title: '동호회', fields: [FieldDef('club', '동호회')])); + var list = await tpl.load(); + expect(list.firstWhere((e) => e.id == company.id).fields.last.key, 'dept'); + expect(list.last.id, 'mine'); + list = await tpl.remove(company.id); + expect(list.any((e) => e.id == company.id), false); + expect(list.last.fields.single.label, '동호회'); + }); + }); +} diff --git a/test/location_test.dart b/test/location_test.dart new file mode 100644 index 0000000..4fc1a8c --- /dev/null +++ b/test/location_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; +import 'package:friendly_map/modules/visibility/visibility.dart'; + +import 'support/app_device.dart'; + +const pa = '+821011112222'; +const pb = '+821022223333'; + +class FakeLocation implements LocationProvider { + GeoPoint? point = const GeoPoint(37.566536, 126.977966); // 서울시청 + @override + Future current() async => point; +} + +Future<(AppDevice, AppDevice, FakeLocation)> scenario({String? relation}) async { + final hub = MemoryHub(); + final loc = FakeLocation(); + final a = await AppDevice.create(hub, '010-1111-2222', pa, '가', location: loc); + final b = await AppDevice.create(hub, '010-2222-3333', pb, '나'); + final person = (await a.app.friends.add(b.phone, b.name))!; + if (relation != null) await a.app.network.addLink(person.id, relation, '시험'); + await AppDevice.settleAll(); + await b.app.friends.accept(a.phone); + await AppDevice.settleAll(); + return (a, b, loc); +} + +void main() { + group('거리 계산', () { + test('서울시청 → 부산시청은 약 325km', () { + const seoul = GeoPoint(37.566536, 126.977966), busan = GeoPoint(35.179800, 129.075000); + final d = distanceMeters(seoul, busan); + expect(d > 320000 && d < 330000, true); + }); + + test('같은 지점은 0, 방위는 북쪽 0도·동쪽 90도', () { + const p = GeoPoint(37, 127); + expect(distanceMeters(p, p), 0); + expect(bearingDegrees(p, const GeoPoint(38, 127)).round(), 0); + expect(bearingDegrees(p, const GeoPoint(37, 128)).round(), 90); + }); + + test('읽기 쉬운 글자', () { + expect(distanceLabel(350), '350 m'); + expect(distanceLabel(2400), '2.4 km'); + }); + }); + + group('위치 공유', () { + test('기본은 꺼짐: 켜기 전에는 아무도에게 보내지 않는다', () async { + final (a, _, _) = await scenario(relation: '가족:동생'); + expect(await a.app.location.sharingOn(), false); + expect(await a.app.location.shareNow(), 0); + }); + + test('가족에게는 정확한 위치가, 친구가 받은 목록에 들어간다', () async { + final (a, b, loc) = await scenario(relation: '가족:동생'); + await a.app.location.setSharing(true); + expect(await a.app.location.shareNow(), 1); + await AppDevice.settleAll(); + final got = await b.app.location.friends(); + expect(got.length, 1); + expect(got.single.person.phone, pa); + expect(got.single.point, loc.point); + }); + + test('관계를 모르는 친구에게는 보내지 않는다', () async { + final (a, b, _) = await scenario(); + await a.app.location.setSharing(true); + expect(await a.app.location.shareNow(), 0); + await AppDevice.settleAll(); + expect(await b.app.location.friends(), isEmpty); + }); + + test('표에서 숨김인 관계(회사)에게는 보내지 않는다, 일부로 바꾸면 약 1km 단위로 뭉개서 보낸다', () async { + final (a, b, _) = await scenario(relation: '전 직장 동료'); + await a.app.location.setSharing(true); + expect(await a.app.location.shareNow(), 0); + var m = await a.app.visibility.cycle(VCategory.location, VAudience.company); // 숨김 → 공개 + m = await a.app.visibility.cycle(VCategory.location, VAudience.company); // 공개 → 일부 + expect(m.get(VCategory.location, VAudience.company), VLevel.partial); + expect(await a.app.location.shareNow(), 1); + await AppDevice.settleAll(); + final p = (await b.app.location.friends()).single.point; + expect(p, const GeoPoint(37.57, 126.98)); + }); + + test('위치를 못 얻으면(권한 없음 등) 0명', () async { + final (a, _, loc) = await scenario(relation: '가족:동생'); + await a.app.location.setSharing(true); + loc.point = null; + expect(await a.app.location.shareNow(), 0); + }); + + test('더 오래된 위치는 새 위치를 덮지 못한다', () async { + final (a, b, loc) = await scenario(relation: '가족:동생'); + await a.app.location.setSharing(true); + await a.app.location.shareNow(); + loc.point = const GeoPoint(35.1798, 129.075); + await Future.delayed(const Duration(milliseconds: 30)); + await a.app.location.shareNow(); + await AppDevice.settleAll(); + expect((await b.app.location.friends()).single.point, const GeoPoint(35.1798, 129.075)); + }); + + test('위치 부품이 없는 기기는 available 이 false', () async { + final (_, b, _) = await scenario(); + expect(b.app.location.available, false); + }); + }); +} diff --git a/test/lunar_test.dart b/test/lunar_test.dart new file mode 100644 index 0000000..4150ef5 --- /dev/null +++ b/test/lunar_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/events/events.dart'; + +void main() { + // 파이썬 korean-lunar-calendar 로 미리 뽑아 둔 무작위 60쌍과 맞춰 본다. + final pairs = <(DateTime, int, int, int, bool)>[ + (DateTime(2010, 2, 7), 2009, 12, 24, false), + (DateTime(1979, 1, 25), 1978, 12, 27, false), + (DateTime(2022, 11, 4), 2022, 10, 11, false), + (DateTime(1960, 8, 30), 1960, 7, 9, false), + (DateTime(1964, 12, 30), 1964, 11, 27, false), + (DateTime(2048, 2, 25), 2048, 1, 12, false), + (DateTime(1968, 11, 20), 1968, 10, 1, false), + (DateTime(2017, 8, 12), 2017, 6, 21, false), + (DateTime(1962, 5, 29), 1962, 4, 26, false), + (DateTime(2043, 1, 18), 2042, 12, 8, false), + (DateTime(1990, 7, 10), 1990, 5, 18, true), + (DateTime(1958, 9, 23), 1958, 8, 11, false), + (DateTime(1967, 6, 3), 1967, 4, 26, false), + (DateTime(2029, 10, 22), 2029, 9, 15, false), + (DateTime(2027, 1, 12), 2026, 12, 5, false), + (DateTime(1964, 7, 14), 1964, 6, 6, false), + (DateTime(1995, 3, 8), 1995, 2, 8, false), + (DateTime(1968, 4, 10), 1968, 3, 13, false), + (DateTime(2028, 3, 3), 2028, 2, 8, false), + (DateTime(1962, 8, 9), 1962, 7, 10, false), + (DateTime(1974, 3, 19), 1974, 2, 26, false), + (DateTime(1992, 1, 21), 1991, 12, 17, false), + (DateTime(1963, 2, 6), 1963, 1, 13, false), + (DateTime(2023, 3, 5), 2023, 2, 14, false), + (DateTime(1960, 11, 23), 1960, 10, 5, false), + (DateTime(1991, 9, 1), 1991, 7, 23, false), + (DateTime(1960, 5, 10), 1960, 4, 15, false), + (DateTime(1975, 11, 23), 1975, 10, 21, false), + (DateTime(2003, 12, 18), 2003, 11, 25, false), + (DateTime(2027, 3, 16), 2027, 2, 9, false), + (DateTime(1977, 11, 18), 1977, 10, 8, false), + (DateTime(1973, 2, 18), 1973, 1, 16, false), + (DateTime(2007, 5, 8), 2007, 3, 22, false), + (DateTime(1984, 6, 5), 1984, 5, 6, false), + (DateTime(1970, 6, 28), 1970, 5, 25, false), + (DateTime(1985, 9, 16), 1985, 8, 2, false), + (DateTime(2018, 10, 26), 2018, 9, 18, false), + (DateTime(1969, 6, 25), 1969, 5, 11, false), + (DateTime(1963, 4, 7), 1963, 3, 14, false), + (DateTime(1962, 9, 11), 1962, 8, 13, false), + (DateTime(1988, 12, 14), 1988, 11, 6, false), + (DateTime(2041, 1, 26), 2040, 12, 24, false), + (DateTime(2047, 5, 28), 2047, 5, 4, false), + (DateTime(2028, 9, 20), 2028, 8, 2, false), + (DateTime(2008, 5, 13), 2008, 4, 9, false), + (DateTime(2035, 7, 17), 2035, 6, 13, false), + (DateTime(2033, 4, 24), 2033, 3, 25, false), + (DateTime(2016, 11, 16), 2016, 10, 17, false), + (DateTime(2005, 10, 14), 2005, 9, 12, false), + (DateTime(1996, 7, 28), 1996, 6, 13, false), + (DateTime(1984, 4, 3), 1984, 3, 3, false), + (DateTime(1995, 10, 19), 1995, 8, 25, true), + (DateTime(1966, 9, 8), 1966, 7, 24, false), + (DateTime(2005, 11, 15), 2005, 10, 14, false), + (DateTime(2046, 3, 27), 2046, 2, 20, false), + (DateTime(2040, 11, 1), 2040, 9, 27, false), + (DateTime(2013, 8, 18), 2013, 7, 12, false), + (DateTime(2032, 7, 13), 2032, 6, 7, false), + (DateTime(2003, 8, 31), 2003, 8, 4, false), + (DateTime(1965, 2, 18), 1965, 1, 17, false), + ]; + + group('음력 변환', () { + test('무작위 60일을 양력 → 음력 → 양력으로 왕복해도 같다', () { + for (final (solar, y, m, d, leap) in pairs) { + expect(solarToLunar(solar), LunarDate(y, m, d, leap: leap), reason: '$solar'); + expect(lunarToSolar(y, m, d, leap: leap), solar, reason: '$y-$m-$d'); + } + }); + + test('알려진 날: 2026 설날=2/17, 추석=9/25, 2026-10-26=음력 9/16', () { + expect(lunarToSolar(2026, 1, 1), DateTime(2026, 2, 17)); + expect(lunarToSolar(2026, 8, 15), DateTime(2026, 9, 25)); + expect(solarToLunar(DateTime(2026, 10, 26)), const LunarDate(2026, 9, 16)); + }); + + test('윤달과 표 밖', () { + expect(lunarToSolar(1952, 5, 1, leap: true), isNotNull); + expect(lunarToSolar(2026, 5, 1, leap: true), isNull); // 2026 은 윤달 없음 + expect(lunarToSolar(1900, 1, 1), isNull); + expect(solarToLunar(DateTime(1940, 1, 1)), isNull); + }); + }); + + group('음력 일정', () { + test('음력 기일은 올해 지났으면 내년 양력 날로, 해가 어긋나는 연말 음력도 찾는다', () { + const e = FamilyEvent(personId: 'a', kind: EventKind.memorial, month: 8, day: 15, lunar: true); + expect(e.nextFrom(DateTime(2026, 10, 26)), lunarToSolar(2027, 8, 15)); + expect(e.nextFrom(DateTime(2026, 9, 25)), DateTime(2026, 9, 25)); + // 음력 12월 말은 양력으로는 다음 해 1~2월 + const f = FamilyEvent(personId: 'a', kind: EventKind.memorial, month: 12, day: 25, lunar: true); + final next = f.nextFrom(DateTime(2026, 1, 10)); + expect(next.isBefore(DateTime(2026, 1, 10)), false); + expect(solarToLunar(next), const LunarDate(2025, 12, 25)); + }); + + test('윤달 기일은 윤달이 든 해에만 잡힌다', () { + // 1952년에는 윤5월이 있었다. 2025년에는 윤6월이 있었다. + const e = FamilyEvent(personId: 'a', kind: EventKind.memorial, month: 6, day: 10, lunar: true, leap: true); + final next = e.nextFrom(DateTime(2026, 1, 1)); + final l = solarToLunar(next)!; + expect((l.month, l.day, l.leap), (6, 10, true)); + expect(next.year > 2026, true); // 2026 에는 윤달 없음 → 이후 윤6월 해 + expect(e.label('할머니'), '할머니 기일(음력 윤달)'); + expect(e.key.contains('LL'), true); + }); + + test('라벨·키에 음력 표시, 30일이 없는 달이면 마지막 날', () { + const e = FamilyEvent(personId: 'a', kind: EventKind.memorial, month: 8, day: 15, lunar: true); + expect(e.label('할아버지'), '할아버지 기일(음력)'); + expect(e.key.contains('L'), true); + final d29 = lunarToSolar(2026, 1, 30); // 정월은 29일까지일 수 있다 + expect(d29, isNotNull); + }); + }); +} diff --git a/test/recovery_service_test.dart b/test/recovery_service_test.dart new file mode 100644 index 0000000..5a61162 --- /dev/null +++ b/test/recovery_service_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/keys/keys.dart'; +import 'package:friendly_map/modules/sync/sync.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'support/app_device.dart'; + +const pa = '+821011112222'; +const pb = '+821022223333'; +const phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +Future<(AppDevice, AppDevice)> friends() async { + final hub = MemoryHub(); + final a = await AppDevice.create(hub, '010-1111-2222', pa, '가'); + final b = await AppDevice.create(hub, '010-2222-3333', pb, '나'); + await a.app.friends.add(b.phone, b.name); + await AppDevice.settleAll(); + await b.app.friends.accept(a.phone); + await AppDevice.settleAll(); + return (a, b); +} + +void main() { + test('조각을 나눠 두면 보관되고, 친구에게 맡기면 친구 기기에 그 조각이 보관된다', () async { + final (a, b) = await friends(); + expect(await a.app.recovery.split(), null); + final codes = await RecoveryShares.split(phrase, n: 3, k: 2); + final s = await a.app.recovery.saveSplit(codes, 2); + expect(s.total, 3); + expect(s.entrusted, 0); + expect(s.free, [0, 1, 2]); + + final bOnA = (await a.app.friends.people()).single; + expect(await a.app.recovery.entrust(bOnA.id, 0), SendResult.sent); + await AppDevice.settleAll(); + + expect((await a.app.recovery.split())!.given, {bOnA.id: 0}); + final held = await b.app.recovery.held(); + expect(held.length, 1); + expect(held.single.ownerPhone, pa); + expect(held.single.ownerName, '가'); + expect(held.single.code, codes[0]); + }); + + test('한 조각은 한 사람에게만, 같은 사람에게 다시 맡기면 그 조각을 새로 보낸다', () async { + final (a, _) = await friends(); + final codes = await RecoveryShares.split(phrase, n: 3, k: 2); + await a.app.recovery.saveSplit(codes, 2); + final p = (await a.app.friends.people()).single; + expect(await a.app.recovery.entrust(p.id, 1), SendResult.sent); + expect(await a.app.recovery.entrust(p.id, 2), SendResult.sent); + expect((await a.app.recovery.split())!.given, {p.id: 2}); + expect(await a.app.recovery.entrust(p.id, 9), SendResult.noTarget); + }); + + test('나눈 조각이 없거나 모르는 사람이면 보내지 않는다', () async { + final (a, _) = await friends(); + expect(await a.app.recovery.entrust('없는사람', 0), SendResult.noTarget); + await a.app.recovery.saveSplit(await RecoveryShares.split(phrase, n: 3, k: 2), 2); + expect(await a.app.recovery.entrust('없는사람', 0), SendResult.noTarget); + }); + + test('친구들이 맡긴 조각이 모이면 단어 12개로 되살아난다', () async { + final hub = MemoryHub(); + final a = await AppDevice.create(hub, '010-1111-2222', pa, '가'); + final f1 = await AppDevice.create(hub, '010-2222-3333', pb, '나'); + final f2 = await AppDevice.create(hub, '010-3333-4444', '+821033334444', '다'); + for (final f in [f1, f2]) { + await a.app.friends.add(f.phone, f.name); + await AppDevice.settleAll(); + await f.app.friends.accept(a.phone); + await AppDevice.settleAll(); + } + await a.app.recovery.saveSplit(await RecoveryShares.split(phrase, n: 3, k: 2), 2); + for (final p in await a.app.friends.people()) { + final free = (await a.app.recovery.split())!.free.first; + expect(await a.app.recovery.entrust(p.id, free), SendResult.sent); + } + await AppDevice.settleAll(); + final codes = [ + ...(await f1.app.recovery.held()).map((h) => h.code), + ...(await f2.app.recovery.held()).map((h) => h.code), + ]; + expect(await RecoveryShares.combine(codes), phrase); + }); +} diff --git a/test/recovery_shares_test.dart b/test/recovery_shares_test.dart new file mode 100644 index 0000000..6ea7b56 --- /dev/null +++ b/test/recovery_shares_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/keys/keys.dart'; + +const phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; + +void main() { + test('5조각 중 3조각이면 단어 12개가 돌아온다 (어느 3조각이든)', () async { + final codes = await RecoveryShares.split(phrase, n: 5, k: 3); + expect(codes.length, 5); + expect(codes.every(RecoveryShares.looksLikeShare), true); + expect(await RecoveryShares.combine([codes[0], codes[2], codes[4]]), phrase); + expect(await RecoveryShares.combine([codes[3], codes[1], codes[0]]), phrase); + expect(await RecoveryShares.combine(codes), phrase); + }); + + test('2조각이면 되살리지 못한다', () async { + final codes = await RecoveryShares.split(phrase, n: 5, k: 3); + expect(await RecoveryShares.combine([codes[0], codes[1]]), null); + }); + + test('같은 조각을 두 번 넣어도 한 장으로 센다', () async { + final codes = await RecoveryShares.split(phrase, n: 5, k: 3); + expect(await RecoveryShares.combine([codes[0], codes[0], codes[1]]), null); + }); + + test('다른 때 나눈 조각이 섞이면 되살리지 못한다', () async { + final a = await RecoveryShares.split(phrase, n: 5, k: 3); + final b = await RecoveryShares.split(phrase, n: 5, k: 3); + expect(await RecoveryShares.combine([a[0], a[1], b[2]]), null); + }); + + test('공백·줄바꿈이 섞여도 읽고, 엉뚱한 글자는 무시', () async { + final codes = await RecoveryShares.split(phrase, n: 3, k: 2); + expect(await RecoveryShares.combine([' ${codes[0]}\n', codes[2], '아무 글자', 'fmr1.깨짐']), phrase); + expect(await RecoveryShares.combine(['아무 글자']), null); + }); + + test('필요한 수가 2 미만이면 만들 수 없다', () async { + expect(() => RecoveryShares.split(phrase, n: 3, k: 1), throwsArgumentError); + }); +} diff --git a/test/shamir_test.dart b/test/shamir_test.dart new file mode 100644 index 0000000..732ad29 --- /dev/null +++ b/test/shamir_test.dart @@ -0,0 +1,48 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/keys/src/shamir.dart'; + +void main() { + final secret = List.generate(16, (i) => (i * 37 + 11) % 256); + + test('5조각 중 어느 3조각으로도 비밀이 돌아온다', () { + final shares = Shamir.split(secret, n: 5, k: 3, random: Random(1)); + expect(shares.length, 5); + for (final pick in [ + [0, 1, 2], [0, 2, 4], [1, 3, 4], [2, 3, 4], [0, 1, 4], + ]) { + expect(Shamir.combine([for (final i in pick) shares[i]]), secret); + } + }); + + test('필요한 수보다 많이 모아도 같은 비밀', () { + final shares = Shamir.split(secret, n: 5, k: 3, random: Random(2)); + expect(Shamir.combine(shares), secret); + }); + + test('2조각만으로는 비밀이 돌아오지 않는다', () { + final shares = Shamir.split(secret, n: 5, k: 3, random: Random(3)); + expect(Shamir.combine([shares[0], shares[1]]) == secret, false); + }); + + test('조각을 글자로 바꿨다 되돌려도 같다', () { + final shares = Shamir.split(secret, n: 4, k: 2, random: Random(4)); + final back = [for (final s in shares.take(2)) Share.decode(s.encode())]; + expect(Shamir.combine(back), secret); + }); + + test('2~10 조각 조합 전부 시험', () { + for (var n = 2; n <= 10; n++) { + for (var k = 2; k <= n; k++) { + final shares = Shamir.split(secret, n: n, k: k, random: Random(n * 100 + k)); + expect(Shamir.combine(shares.sublist(n - k)), secret); + } + } + }); + + test('잘못된 수는 거절', () { + expect(() => Shamir.split(secret, n: 2, k: 3), throwsArgumentError); + expect(() => Shamir.split(secret, n: 5, k: 1), throwsArgumentError); + }); +} diff --git a/test/sharing_service_test.dart b/test/sharing_service_test.dart new file mode 100644 index 0000000..d49a4dd --- /dev/null +++ b/test/sharing_service_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/sharing/sharing.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +void main() { + late Storage s; + late ShareService svc; + var now = 100; + setUp(() async { + s = Storage.memory(); + now = 100; + svc = ShareService(s, _TickClock(() => now)); + await s.myCards.put(const MyCard(id: 'c1', kind: 'company', title: '회사', fieldsJson: '{"a":"1"}', updatedAt: 50)); + await s.rooms.put(const ChatRoom( + id: 'r1', title: '방', kind: 'direct', contextKind: 'personal', membersJson: '[]', updatedAt: 1)); + }); + + test('명함 공유는 그 순간의 내용을 담고, 같은 상대에게 두 번 공유해도 하나', () async { + final a = (await svc.shareCard('c1', 'p'))!; + expect(a.snapshot, '{"a":"1"}'); + expect(a.sentVersion, 50); + expect((await svc.shareCard('c1', 'p'))!.id, a.id); + expect(await svc.shareCard('없음', 'p'), null); + }); + + test('고친 뒤에는 다시 보내기 대상이 되고, 다시 보내면 새 내용이 담김', () async { + final a = (await svc.shareCard('c1', 'p'))!; + expect(await svc.needingResend(), isEmpty); + await s.myCards.put(const MyCard(id: 'c1', kind: 'company', title: '회사', fieldsJson: '{"a":"2"}', updatedAt: 80)); + expect((await svc.needingResend()).single.id, a.id); + now = 200; + final re = (await svc.resend(a.id))!; + expect(re.snapshot, '{"a":"2"}'); + expect(re.sentVersion, 80); + expect(await svc.needingResend(), isEmpty); + }); + + test('공유를 끝내면 다시 보내기 대상이 아니고 resend 도 안 됨', () async { + final a = (await svc.shareCard('c1', 'p'))!; + await svc.stop(a.id); + expect(await svc.resend(a.id), null); + expect(await svc.needingResend(), isEmpty); + }); + + test('대화방 공유와 맥락 규칙 (개인은 명함 없이, 그 밖은 같은 종류 명함 공유 필요)', () async { + expect((await svc.shareRoom('r1', 'p'))!.unit, 'room'); + expect(await svc.shareRoom('없음', 'p'), null); + expect(await svc.canStartContextRoom('personal', 'p'), true); + expect(await svc.canStartContextRoom('company', 'p'), false); + await svc.shareCard('c1', 'p'); + expect(await svc.canStartContextRoom('company', 'p'), true); + expect(await svc.canStartContextRoom('company', 'other'), false); + expect(await svc.canStartContextRoom('school', 'p'), false); + }); +} + +class _TickClock extends Clock { + _TickClock(this._f); + final int Function() _f; + @override + int now() => _f(); +} diff --git a/test/sharing_status_test.dart b/test/sharing_status_test.dart new file mode 100644 index 0000000..91e73bd --- /dev/null +++ b/test/sharing_status_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/sharing/sharing.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +ShareRecord _card({int? sent = 100, int? ended}) => ShareRecord( + id: 's', unit: 'card', targetId: 'c', peerId: 'p', + startedAt: 1, endedAt: ended, sentVersion: sent, updatedAt: 1, + ); + +void main() { + test('보낸 뒤 명함을 고치지 않았으면 최신', () { + expect(shareStatusOf(_card(), 100), ShareStatus.upToDate); + }); + + test('보낸 뒤 명함을 고쳤으면 다시 보내야 함', () { + expect(shareStatusOf(_card(), 101), ShareStatus.needsResend); + }); + + test('끝낸 공유는 끝남', () { + expect(shareStatusOf(_card(ended: 5), 999), ShareStatus.ended); + }); + + test('대화방 공유는 다시 보낼 것이 없음', () { + const room = ShareRecord( + id: 's', unit: 'room', targetId: 'r', peerId: 'p', startedAt: 1, updatedAt: 1, + ); + expect(shareStatusOf(room, 999), ShareStatus.upToDate); + }); + + test('다시 보내기는 내용과 시점만 바꾼다', () { + final r = _card().resent(snapshot: '{}', sentVersion: 200, updatedAt: 201); + expect(r.sentVersion, 200); + expect(r.snapshot, '{}'); + expect(r.id, 's'); + expect(r.startedAt, 1); + }); +} diff --git a/test/storage_memory_test.dart b/test/storage_memory_test.dart new file mode 100644 index 0000000..52b3279 --- /dev/null +++ b/test/storage_memory_test.dart @@ -0,0 +1,7 @@ +import 'package:friendly_map/modules/storage/storage.dart'; + +import 'support/storage_contract.dart'; + +void main() { + storageContract('메모리', () async => Storage.memory()); +} diff --git a/test/storage_models_test.dart b/test/storage_models_test.dart new file mode 100644 index 0000000..d738def --- /dev/null +++ b/test/storage_models_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +void main() { + test('내 명함 맵 변환 왕복', () { + const c = MyCard( + id: 'a', kind: 'company', title: '회사 명함', + fieldsJson: '{"name":"이미영"}', updatedAt: 123, shareDefault: true, + ); + final back = MyCard.fromMap(c.toMap()); + expect(back.id, 'a'); + expect(back.shareDefault, true); + expect(back.updatedAt, 123); + }); + + test('대화 맵 변환 왕복', () { + const m = Message( + id: 'm', roomId: 'r', senderId: 's', body: '안녕', sentAt: 1, updatedAt: 2, + ); + final back = Message.fromMap(m.toMap()); + expect(back.body, '안녕'); + expect(back.archived, false); + }); + + test('설정 기본값은 텍스트 1MB, 저장 공간 80%, 복구 조각 5개 중 3개', () { + final s = AppSettings.fromMap({}); + expect(s.separateTextBytes, 1024 * 1024); + expect(s.storageUsagePercent, 80); + expect(s.recoveryShares, 5); + expect(s.recoveryThreshold, 3); + }); + + test('설정 저장 형태 왕복', () { + const s = AppSettings(storageUsagePercent: 90, exchangeMode: 'auto'); + final back = AppSettings.fromMap(s.toMap()); + expect(back.storageUsagePercent, 90); + expect(back.exchangeMode, 'auto'); + }); + + test('ID 는 32자 16진수이고 매번 다르다', () { + final a = newId(); + final b = newId(); + expect(a.length, 32); + expect(a == b, false); + }); + + test('전화번호는 어떻게 적어도 같은 키가 된다', () { + const want = '+821028475821'; + expect(normalizePhone('010-2847-5821'), want); + expect(normalizePhone('01028475821'), want); + expect(normalizePhone('+82 10-2847-5821'), want); + expect(normalizePhone('821028475821'), want); + expect(normalizePhone('0082 10 2847 5821'), want); + }); + + test('번호가 아니면 null', () { + expect(normalizePhone(''), null); + expect(normalizePhone('abc'), null); + expect(normalizePhone('123'), null); + }); + + test('사람은 번호표(id)와 현재 번호·이름을 가진다', () { + const p = Person( + id: 'p1', name: '정민호', phone: '+821028475821', createdAt: 1, updatedAt: 2); + final back = Person.fromMap(p.toMap()); + expect(back.id, 'p1'); + expect(back.phone, '+821028475821'); + expect(back.name, '정민호'); + }); +} diff --git a/test/storage_sqlite_test.dart b/test/storage_sqlite_test.dart new file mode 100644 index 0000000..421367e --- /dev/null +++ b/test/storage_sqlite_test.dart @@ -0,0 +1,14 @@ +import 'package:friendly_map/modules/storage/storage_sqlite.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'support/storage_contract.dart'; + +void main() { + sqfliteFfiInit(); + storageContract('SQLite', () async { + // 같은 ":memory:" 경로라도 시험마다 새 데이터베이스를 연다 (singleInstance 면 이전 것이 재사용됨). + final db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath, + options: OpenDatabaseOptions(singleInstance: false)); + return openSqliteStorage(db); + }); +} diff --git a/test/support/app_device.dart b/test/support/app_device.dart new file mode 100644 index 0000000..cc43ed0 --- /dev/null +++ b/test/support/app_device.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:friendly_map/modules/app/app.dart'; +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'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'fake_cipher.dart'; +import 'storage_contract.dart' show FakeProbe; + +/// 시험용 기기 하나: 화면 없이 앱 내부 전체를 올린다. +class AppDevice { + AppDevice._(this.app, this.phone, this.name, this.key); + + final FriendlyApp app; + final String phone; // 표준 형태 + final String name; + final String key; + + static final List _all = []; + + static Future create(MemoryHub hub, String rawPhone, String normalized, String name, + {bool start = true, FakeProbe? probe, FakeLan? lan, LocationProvider? location, + PhoneVerifier? verifier, + CallLogProvider? callLog, + PhoneActions? phone, + Notifier? notifier}) async { + final key = 'KEY-$name'; + final app = FriendlyApp(AppDeps( + storage: Storage.memory(), + transport: MemoryTransport(normalized, hub), + cipher: FakeCipher(key), + identity: FakeIdentity(key), + probe: probe ?? FakeProbe(10), + lan: lan == null ? null : LanDirectory(lan), + location: location, + verifier: verifier, + callLog: callLog, + phone: phone, + notifier: notifier, + )); + final d = AppDevice._(app, normalized, name, key); + await app.profile.setUp(rawPhone, name); + _all.add(d); + if (start) await app.start(); + return d; + } + + static Future settleAll() async { + for (var round = 0; round < 3; round++) { + for (final d in _all) { + await d.app.idle(); + } + } + } +} + +/// 시험용 같은 네트워크 발견: 테스트가 직접 상대를 흘려 넣는다. +class FakeLan implements LanDiscovery { + final StreamController ctl = StreamController.broadcast(sync: true); + @override + Future advertise({required String phone, required int port}) async {} + @override + Stream get peers => ctl.stream; + @override + Future start() async {} + @override + Future stop() async {} +} diff --git a/test/support/device.dart b/test/support/device.dart new file mode 100644 index 0000000..4128116 --- /dev/null +++ b/test/support/device.dart @@ -0,0 +1,85 @@ +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/sync/sync.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'fake_cipher.dart'; + +/// 시험용 기기 하나: 저장소 + 연결 + 동기화 엔진. +class Device { + Device._(this.phone, this.name, this.key, this.storage, this.transport, this.engine, this.events); + + final String phone; + final String name; + final String key; + final Storage storage; + final MemoryTransport transport; + final SyncEngine engine; + final List events; + + static final List _all = []; + + /// 모든 시험 기기에서 처리 중인 받은 봉투가 끝나기를 기다린다. + static Future settleAll() async { + for (final d in _all) { + await d.engine.idle(); + } + } + + static Future create(MemoryHub hub, String phone, String name, {bool start = true}) async { + final storage = Storage.memory(); + await storage.settings.saveProfile(MyProfile(phone: phone, name: name), 1); + final t = MemoryTransport(phone, hub); + final key = 'KEY-$name'; + final engine = SyncEngine( + storage: storage, + transport: t, + cipher: FakeCipher(key), + identity: FakeIdentity(key), + clock: Clock(), + ); + final events = []; + engine.events.listen(events.add); + final d = Device._(phone, name, key, storage, t, engine, events); + _all.add(d); + if (start) await engine.start(); + return d; + } + + /// 상대를 알고 있는 사이로 만든다 (키까지 교환 끝난 상태). + /// 이 기기의 암호로 [peerKey] 에게 보낼 내용을 잠근다 (조작된 봉투 시험용). + Future engineCipherSeal(String peerKey, String context, String plain) => + FakeCipher(key).seal(plain: plain, peerPublicKey: peerKey, context: context); + + Future know(Device other) async => + (await storage.people.register(other.phone, other.name, 2, publicKey: other.key))!; + + Future addCard(String kind, {String fields = '{"company":"한빛"}', String? id}) async { + final c = MyCard(id: id ?? 'card-$kind-$name', kind: kind, title: kind, fieldsJson: fields, updatedAt: 5); + await storage.myCards.put(c); + return c; + } + + Future shareCard(MyCard card, Person peer) async { + final s = ShareRecord( + id: 'sh-${card.id}-${peer.id}', unit: 'card', targetId: card.id, peerId: peer.id, + startedAt: 10, snapshot: card.fieldsJson, sentVersion: card.updatedAt, updatedAt: 10); + await storage.shares.put(s); + return s; + } + + Future addRoom(String id, String context, List members) async { + final r = ChatRoom( + id: id, title: '방 $id', kind: members.length > 1 ? 'group' : 'direct', contextKind: context, + membersJson: '[${members.map((p) => '"${p.id}"').join(',')}]', updatedAt: 10); + await storage.rooms.put(r); + return r; + } + + Future shareRoom(ChatRoom room, Person peer) async { + final s = ShareRecord( + id: 'sr-${room.id}-${peer.id}', unit: 'room', targetId: room.id, peerId: peer.id, + startedAt: 10, updatedAt: 10); + await storage.shares.put(s); + return s; + } +} diff --git a/test/support/envelopes.dart b/test/support/envelopes.dart new file mode 100644 index 0000000..8aa806e --- /dev/null +++ b/test/support/envelopes.dart @@ -0,0 +1,4 @@ +import 'package:friendly_map/modules/core/core.dart'; + +Envelope envelope(String id, {String from = '+821011112222', String to = '+821022223333', String body = 'SEALED'}) => + Envelope(id: id, from: from, to: to, kind: 'chat.message', unitId: 'room1', body: body, stamp: 1); diff --git a/test/support/fake_cipher.dart b/test/support/fake_cipher.dart new file mode 100644 index 0000000..a179743 --- /dev/null +++ b/test/support/fake_cipher.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; + +import 'package:friendly_map/modules/core/core.dart'; + +/// 시험용 암호: 진짜 암호는 아니고, "두 사람의 키 + 단위 ID 가 맞아야 풀린다"는 약속만 흉내 낸다. +class FakeCipher implements PayloadCipher { + FakeCipher(this.myKey); + final String myKey; + + String _tag(String peer, String context) => '${([myKey, peer]..sort()).join('+')}|$context'; + + @override + Future seal({required String plain, required String peerPublicKey, required String context}) async => + '${_tag(peerPublicKey, context)}|${base64Encode(utf8.encode(plain))}'; + + @override + Future open({required String sealed, required String peerPublicKey, required String context}) async { + final tag = _tag(peerPublicKey, context); + if (!sealed.startsWith('$tag|')) return null; + return utf8.decode(base64Decode(sealed.substring(tag.length + 1))); + } +} + +class FakeIdentity implements IdentityProvider { + FakeIdentity(this.key); + final String key; + @override + Future myPublicKey() async => key; +} diff --git a/test/support/storage_contract.dart b/test/support/storage_contract.dart new file mode 100644 index 0000000..22f90ee --- /dev/null +++ b/test/support/storage_contract.dart @@ -0,0 +1,237 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/platform/platform.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; + +class FakeProbe implements DeviceStorageProbe { + FakeProbe(this.percent); + int percent; + @override + Future usedPercent() async => percent; +} + +MyCard card(String id, String kind, {int at = 1}) => + MyCard(id: id, kind: kind, title: '$kind 명함', fieldsJson: '{}', updatedAt: at); + +Message msg(String id, int at, {String body = 'hi', String room = 'r1'}) => + Message(id: id, roomId: room, senderId: 's', body: body, sentAt: at, updatedAt: at); + +/// 저장 방식(메모리, SQLite)이 같은 약속을 지키는지 확인하는 공통 시험. +/// 새 저장 방식을 만들면 이 시험에 넣어 보면 된다. +void storageContract(String label, Future Function() make) { + group('$label 저장소', () { + late Storage s; + setUp(() async => s = await make()); + + test('내 명함: 저장, 읽기, 종류별 찾기, 같은 id 는 덮어씀', () async { + await s.myCards.put(card('a', 'company')); + await s.myCards.put(card('b', 'school')); + await s.myCards.put(card('a', 'company', at: 5)); + expect((await s.myCards.get('a'))!.updatedAt, 5); + expect((await s.myCards.all()).length, 2); + expect((await s.myCards.byKind('school')).single.id, 'b'); + expect(await s.myCards.updatedAtOf('a'), 5); + expect(await s.myCards.updatedAtOf('none'), null); + expect(await s.myCards.get('none'), null); + }); + + test('지우기: 명함·받은 명함·연결 고리, 없는 것을 지워도 문제 없음', () async { + await s.myCards.put(card('a', 'company')); + await s.myCards.remove('a'); + await s.myCards.remove('none'); + expect(await s.myCards.get('a'), null); + await s.links.put(const Link(id: 'l', personId: 'p', relation: '동창', detail: '', updatedAt: 1)); + await s.links.remove('l'); + expect(await s.links.byPerson('p'), isEmpty); + await s.receivedCards.put(ReceivedCard( + id: 'r', personId: 'p', kind: 'school', fieldsJson: '{}', receivedAt: 1, updatedAt: 1)); + await s.receivedCards.remove('r'); + expect(await s.receivedCards.byPerson('p'), isEmpty); + }); + + test('받은 명함은 사람별로 최근 것부터', () async { + ReceivedCard rc(String id, String p, int at) => ReceivedCard( + id: id, personId: p, kind: 'school', fieldsJson: '{}', receivedAt: at, updatedAt: at); + await s.receivedCards.put(rc('1', 'p', 10)); + await s.receivedCards.put(rc('2', 'p', 30)); + await s.receivedCards.put(rc('3', 'q', 20)); + expect((await s.receivedCards.byPerson('p')).map((c) => c.id).toList(), ['2', '1']); + }); + + test('사람: 번호가 같으면 같은 사람, 이름만 다르면 이름 변경 후보', () async { + final p = await s.people.register('+821011112222', '정민호', 100); + expect(p, isNotNull); + expect((await s.people.match('+821011112222', '정민호')).kind, MatchKind.same); + final m = await s.people.match('+821011112222', '정민우'); + expect(m.kind, MatchKind.nameDiffers); + expect(m.candidates.single.id, p!.id); + }); + + test('사람: 이름이 같고 번호가 처음이면 번호 변경 후보, 둘 다 처음이면 새 사람', () async { + await s.people.register('+821011112222', '정민호', 100); + final m = await s.people.match('+821033334444', '정민호'); + expect(m.kind, MatchKind.phoneDiffers); + expect(m.candidates, hasLength(1)); + expect((await s.people.match('+821055556666', '김하늘')).kind, MatchKind.none); + }); + + test('사람: 같은 번호 중복 등록은 막힘', () async { + await s.people.register('+821011112222', '정민호', 100); + expect(await s.people.register('+821011112222', '다른이름', 101), null); + }); + + test('사람: 번호를 바꿔도 id 는 그대로, 이전 번호로도 찾아짐', () async { + final p = (await s.people.register('+821011112222', '정민호', 100))!; + expect(await s.people.changePhone(p.id, '+821099998888', 200), true); + final now = (await s.people.get(p.id))!; + expect(now.phone, '+821099998888'); + expect((await s.people.byPhone('+821011112222'))!.id, p.id); + expect((await s.people.byPhone('+821099998888'))!.id, p.id); + expect((await s.people.match('+821011112222', '정민호')).kind, MatchKind.same); + }); + + test('사람: 새 번호가 다른 사람 것이면 번호 변경 거절', () async { + final a = (await s.people.register('+821011112222', '가', 100))!; + await s.people.register('+821033334444', '나', 100); + expect(await s.people.changePhone(a.id, '+821033334444', 200), false); + expect((await s.people.get(a.id))!.phone, '+821011112222'); + }); + + test('사람: 이름을 바꿔도 이전 이름으로 찾아지고 같은 사람으로 봄', () async { + final p = (await s.people.register('+821011112222', '정민호', 100))!; + expect(await s.people.changeName(p.id, '정민우', 200), true); + expect((await s.people.get(p.id))!.name, '정민우'); + expect((await s.people.byName('정민호')).single.id, p.id); + expect((await s.people.byName('정민우')).single.id, p.id); + expect((await s.people.match('+821011112222', '정민호')).kind, MatchKind.same); + }); + + test('사람: 공개 키를 붙인다', () async { + final p = (await s.people.register('+821011112222', '정민호', 100))!; + await s.people.setPublicKey(p.id, 'KEY', 300); + expect((await s.people.get(p.id))!.publicKey, 'KEY'); + }); + + test('대화: 시간순, before, limit, 별도 저장 제외', () async { + for (var i = 1; i <= 5; i++) { + await s.messages.put(msg('m$i', i * 10)); + } + await s.messages.put(msg('x', 5, room: 'r2')); + expect((await s.messages.page('r1')).map((m) => m.id).toList(), ['m1', 'm2', 'm3', 'm4', 'm5']); + expect((await s.messages.page('r1', limit: 2)).map((m) => m.id).toList(), ['m4', 'm5']); + expect((await s.messages.page('r1', before: 30, limit: 10)).map((m) => m.id).toList(), ['m1', 'm2']); + }); + + test('대화: 텍스트 크기는 UTF-8 바이트, 범위는 처음~끝', () async { + await s.messages.put(msg('a', 10, body: 'abc')); + await s.messages.put(msg('b', 20, body: '가나')); + expect(await s.messages.textBytes('r1'), 3 + 6); + expect(await s.messages.span('r1'), (10, 20)); + expect(await s.messages.span('빈방'), null); + expect(await s.messages.textBytes('빈방'), 0); + }); + + test('대화: 범위를 별도 저장으로 나누고 되돌림 (지우지 않음)', () async { + for (var i = 1; i <= 4; i++) { + await s.messages.put(msg('m$i', i * 10, body: 'xx')); + } + expect(await s.messages.setArchivedBetween('r1', 10, 20, 99), 2); + expect((await s.messages.page('r1')).map((m) => m.id).toList(), ['m3', 'm4']); + expect(await s.messages.textBytes('r1'), 4); + expect(await s.messages.span('r1'), (30, 40)); + expect((await s.messages.get('m1'))!.archived, true); + expect(await s.messages.setArchivedBetween('r1', 10, 20, 100, archived: false), 2); + expect((await s.messages.page('r1')).length, 4); + }); + + test('대화방: 최근 순서, 별도 저장된 방은 기본에서 빠짐', () async { + ChatRoom room(String id, int at, {bool archived = false}) => ChatRoom( + id: id, title: id, kind: 'direct', contextKind: 'personal', + membersJson: '[]', archived: archived, updatedAt: at); + await s.rooms.put(room('a', 10)); + await s.rooms.put(room('b', 30)); + await s.rooms.put(room('c', 20, archived: true)); + expect((await s.rooms.recent()).map((r) => r.id).toList(), ['b', 'a']); + expect((await s.rooms.recent(includeArchived: true)).map((r) => r.id).toList(), ['b', 'c', 'a']); + }); + + test('공유: 지금 공유 중인 것만 찾고, 끝내면 빠짐', () async { + ShareRecord sr(String id, String unit, String target) => ShareRecord( + id: id, unit: unit, targetId: target, peerId: 'p', startedAt: 1, updatedAt: 1); + await s.shares.put(sr('1', 'card', 'c1')); + await s.shares.put(sr('2', 'room', 'r1')); + expect((await s.shares.activeFor('c1')).single.id, '1'); + expect((await s.shares.activeOf('room')).single.id, '2'); + await s.shares.end('1', 50); + expect(await s.shares.activeFor('c1'), isEmpty); + final ended = (await s.shares.get('1'))!; + expect(ended.endedAt, 50); + expect(ended.active, false); + }); + + test('보낼함: 오래된 것부터, 갱신, 삭제', () async { + OutboxItem o(String id, int at) => + OutboxItem(id: id, peerPhone: '+82', envelopeJson: '{}', createdAt: at); + await s.outbox.add(o('b', 20)); + await s.outbox.add(o('a', 10)); + expect((await s.outbox.pending()).map((x) => x.id).toList(), ['a', 'b']); + await s.outbox.update(o('a', 10).tried()); + expect((await s.outbox.pending()).first.attempts, 1); + await s.outbox.remove('a'); + expect((await s.outbox.pending()).single.id, 'b'); + }); + + test('친구 요청: 대기 중인 것만, 상태 바꾸면 빠짐', () async { + final r = FriendRequest( + id: '+821011112222', name: '정민호', publicKey: 'K', receivedAt: 5, updatedAt: 5); + await s.friendRequests.put(r); + expect((await s.friendRequests.pending()).single.phone, '+821011112222'); + await s.friendRequests.put(r.withStatus('accepted', 9)); + expect(await s.friendRequests.pending(), isEmpty); + }); + + test('연결 고리: 사람별', () async { + await s.links.put(const Link(id: '1', personId: 'p', relation: '동창', detail: '가람초', updatedAt: 1)); + await s.links.put(const Link(id: '2', personId: 'q', relation: '동문', detail: '한국대', updatedAt: 1)); + expect((await s.links.byPerson('p')).single.relation, '동창'); + }); + + test('설정: 기본값, 저장 후 읽기, 내 정보', () async { + expect((await s.settings.load()).separateTextBytes, 1024 * 1024); + expect(await s.settings.loadProfile(), null); + await s.settings.save(const AppSettings(storageUsagePercent: 90, exchangeMode: 'auto'), 1); + final back = await s.settings.load(); + expect(back.storageUsagePercent, 90); + expect(back.exchangeMode, 'auto'); + await s.settings.saveProfile(const MyProfile(phone: '+82101', name: '나'), 2); + expect((await s.settings.loadProfile())!.name, '나'); + await s.settings.putText('home_layout', '{"a":1}', 3); + expect(await s.settings.getText('home_layout'), '{"a":1}'); + }); + + test('이름-값: 봉투 처리 기록과 충돌 기록', () async { + expect(await s.seen.get('e1'), null); + await s.seen.put('e1', '1', 1); + expect(await s.seen.get('e1'), '1'); + await s.meta.put('row1', 'author|5', 1); + expect(await s.meta.get('row1'), 'author|5'); + }); + + test('별도 저장 권고와 나누기', () async { + for (var i = 1; i <= 3; i++) { + await s.messages.put(msg('m$i', i * 10, body: 'x' * 400000)); + } + final probe = FakeProbe(10); + final svc = ArchiveService( + messages: s.messages, settings: s.settings, clock: Clock(), probe: probe); + final advice = await svc.adviseRoom('r1'); + expect(advice.overText, true); + expect(advice.overDisk, false); + expect(await svc.selectableSpan('r1'), (10, 30)); + expect(await svc.archiveRange('r1', 10, 20), 2); + expect((await svc.adviseRoom('r1')).overText, false); + probe.percent = 85; + expect((await svc.adviseRoom('r1')).overDisk, true); + expect(await svc.restoreRange('r1', 10, 20), 2); + }); + }); +} diff --git a/test/sync_test.dart b/test/sync_test.dart new file mode 100644 index 0000000..d112654 --- /dev/null +++ b/test/sync_test.dart @@ -0,0 +1,268 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/storage/storage.dart'; +import 'package:friendly_map/modules/sync/sync.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'support/device.dart'; + +const pa = '+821011112222'; +const pb = '+821022223333'; + +Message message(String id, String room, {String body = '안녕'}) => + Message(id: id, roomId: room, senderId: 'me', body: body, sentAt: 100, updatedAt: 100); + +void main() { + group('인사', () { + test('인사를 보내면 상대에게 이름·공개 키가 알려지고, 답 인사로 내 쪽도 키를 알게 됨', () async { + final hub = MemoryHub(); + final a = await Device.create(hub, pa, '가'); + final b = await Device.create(hub, pb, '나'); + expect(await a.engine.sendHello(pb), SendResult.sent); + await Device.settleAll(); + final hello = b.events.single; + expect(hello.type, SyncEventType.hello); + expect(hello.phone, pa); + expect(hello.name, '가'); + expect(hello.publicKey, a.key); + expect(hello.isReply, false); + await b.engine.sendHello(pa, reply: true); + await Device.settleAll(); + expect(a.events.single.isReply, true); + expect(a.events.single.publicKey, b.key); + }); + + test('받는 사람이 아닌 봉투는 무시', () async { + final hub = MemoryHub(); + final b = await Device.create(hub, pb, '나'); + final e = Envelope(id: 'x', from: pa, to: '+821099999999', kind: 'hello', unitId: '', + body: '{"name":"가","key":"K","reply":false}', stamp: 1); + expect(await b.engine.receive(e), Outcome.ignored); + expect(b.events, isEmpty); + }); + }); + + group('명함 공유', () { + test('공유한 명함이 상대 받은 명함에 들어가고, 고쳐서 다시 보내면 갱신', () async { + final hub = MemoryHub(); + final a = await Device.create(hub, pa, '가'); + final b = await Device.create(hub, pb, '나'); + final pBofA = await a.know(b); + final pAofB = await b.know(a); + final card = await a.addCard('company'); + final share = await a.shareCard(card, pBofA); + expect(await a.engine.sendCard(share), SendResult.sent); + await Device.settleAll(); + final got = (await b.storage.receivedCards.get(card.id))!; + expect(got.personId, pAofB.id); + expect(got.kind, 'company'); + expect(got.fieldsJson, '{"company":"한빛"}'); + final edited = share.resent(snapshot: '{"company":"새빛"}', sentVersion: 9, updatedAt: 20); + await a.storage.shares.put(edited); + await a.engine.sendCard(edited); + await Device.settleAll(); + expect((await b.storage.receivedCards.get(card.id))!.fieldsJson, '{"company":"새빛"}'); + expect((await b.storage.receivedCards.all()).length, 1); + }); + + test('상대가 꺼져 있어도 보관함을 거쳐 나중에 받음', () async { + final hub = MemoryHub(); + final a = await Device.create(hub, pa, '가'); + final b = await Device.create(hub, pb, '나', start: false); + final pBofA = await a.know(b); + await b.know(a); + final card = await a.addCard('school'); + expect(await a.engine.sendCard(await a.shareCard(card, pBofA)), SendResult.sent); + await Device.settleAll(); + expect(await b.storage.receivedCards.all(), isEmpty); + await b.engine.start(); // 켜면서 보관함에서 가져옴 + expect((await b.storage.receivedCards.all()).single.kind, 'school'); + }); + }); + + group('보낼함', () { + test('아무 길도 없으면 보낼함에 쌓이고, 길이 생기면 다시 보냄', () async { + final hub = MemoryHub()..mailboxEnabled = false; + final a = await Device.create(hub, pa, '가'); + final b = await Device.create(hub, pb, '나', start: false); + final pBofA = await a.know(b); + await b.know(a); + final card = await a.addCard('group'); + expect(await a.engine.sendCard(await a.shareCard(card, pBofA)), SendResult.queued); + await Device.settleAll(); + expect((await a.storage.outbox.pending()).length, 1); + expect(await a.engine.flushOutbox(), 0); + await Device.settleAll(); + expect((await a.storage.outbox.pending()).single.attempts, 1); + hub.mailboxEnabled = true; + await b.engine.start(); + await Device.settleAll(); + expect(await a.engine.flushOutbox(), 1); + await Device.settleAll(); + expect(await a.storage.outbox.pending(), isEmpty); + await b.engine.refresh(); + await Device.settleAll(); + expect((await b.storage.receivedCards.all()).length, 1); + }); + + test('상대 키를 모르면 보내지 않고 알려 줌', () async { + final hub = MemoryHub(); + final a = await Device.create(hub, pa, '가'); + final p = (await a.storage.people.register(pb, '나', 2))!; + final card = await a.addCard('company'); + expect(await a.engine.sendCard(await a.shareCard(card, p)), SendResult.noKey); + await Device.settleAll(); + expect(await a.storage.outbox.pending(), isEmpty); + }); + }); + + group('대화', () { + Future<(Device, Device, Person, Person)> pair() async { + final hub = MemoryHub(); + final a = await Device.create(hub, pa, '가'); + final b = await Device.create(hub, pb, '나'); + return (a, b, await a.know(b), await b.know(a)); + } + + test('개인 대화방은 명함 없이 열리고 대화가 전달됨', () async { + final (a, b, pBofA, pAofB) = await pair(); + final room = await a.addRoom('r1', 'personal', [pBofA]); + await a.engine.sendRoom(await a.shareRoom(room, pBofA)); + await Device.settleAll(); + final m = message('m1', 'r1'); + await a.storage.messages.put(m); + await a.engine.sendMessage(m); + await Device.settleAll(); + final r = (await b.storage.rooms.get('r1'))!; + expect(r.contextKind, 'personal'); + expect(r.membersJson, '["${pAofB.id}"]'); + final got = (await b.storage.messages.get('m1'))!; + expect(got.body, '안녕'); + expect(got.senderId, pAofB.id); + }); + + test('받은 방에서 내가 쓴 대화도 상대에게 감 (받은 방도 공유 기록이 생김)', () async { + final (a, b, pBofA, pAofB) = await pair(); + final room = await a.addRoom('r6', 'personal', [pBofA]); + await a.engine.sendRoom(await a.shareRoom(room, pBofA)); + await Device.settleAll(); + expect((await b.storage.shares.activeFor('r6')).single.peerId, pAofB.id); + final m = message('m6', 'r6', body: '답장'); + await b.storage.messages.put(m); + await b.engine.sendMessage(m); + await Device.settleAll(); + expect((await a.storage.messages.get('m6'))!.body, '답장'); + }); + + test('회사 맥락 방은 상대의 회사 명함을 받은 뒤에야 열림', () async { + final (a, b, pBofA, _) = await pair(); + final room = await a.addRoom('r2', 'company', [pBofA]); + await a.engine.sendRoom(await a.shareRoom(room, pBofA)); + await Device.settleAll(); + expect(await b.storage.rooms.get('r2'), null); + final card = await a.addCard('company'); + await a.engine.sendCard(await a.shareCard(card, pBofA)); + await Device.settleAll(); + expect((await b.storage.rooms.get('r2'))!.contextKind, 'company'); + }); + + test('방 정보보다 대화가 먼저 와도 보류했다가 반영', () async { + final (a, b, pBofA, _) = await pair(); + final room = await a.addRoom('r3', 'personal', [pBofA]); + final share = await a.shareRoom(room, pBofA); + final m = message('m3', 'r3'); + await a.engine.sendMessage(m); + await Device.settleAll(); + expect(await b.storage.messages.get('m3'), null); + await a.engine.sendRoom(share); + await Device.settleAll(); + expect((await b.storage.messages.get('m3'))!.body, '안녕'); + }); + + test('같은 봉투가 두 번 와도 한 번만 반영', () async { + final (a, b, pBofA, _) = await pair(); + final room = await a.addRoom('r4', 'personal', [pBofA]); + await a.engine.sendRoom(await a.shareRoom(room, pBofA)); + await Device.settleAll(); + final env = Envelope( + id: 'dup', from: pa, to: pb, kind: 'chat.message', unitId: 'r4', + body: await a.engineCipherSeal(b.key, 'r4', '{"id":"m4","body":"한번","sentAt":5}'), stamp: 7); + expect(await b.engine.receive(env), Outcome.applied); + expect(await b.engine.receive(env), Outcome.ignored); + expect((await b.storage.messages.page('r4')).length, 1); + }); + + test('모르는 사람이 보낸 대화는 보류, 사람을 등록하고 키를 알면 반영', () async { + final hub = MemoryHub(); + final a = await Device.create(hub, pa, '가'); + final b = await Device.create(hub, pb, '나'); + final pBofA = await a.know(b); + final room = await a.addRoom('r5', 'personal', [pBofA]); + await a.engine.sendRoom(await a.shareRoom(room, pBofA)); + await Device.settleAll(); + expect(await b.storage.rooms.get('r5'), null); // 아직 가를 모름 + await b.know(a); + expect(await b.engine.retryHeld(), 1); + expect((await b.storage.rooms.get('r5')), isNotNull); + }); + + test('키가 다른 사람이 보낸 봉투는 무시', () async { + final hub = MemoryHub(); + final b = await Device.create(hub, pb, '나'); + await b.storage.people.register(pa, '가', 2, publicKey: 'KEY-가'); + final env = Envelope(id: 'bad', from: pa, to: pb, kind: 'card.share', unitId: 'c9', + body: 'forged|c9|e30=', stamp: 5); + expect(await b.engine.receive(env), Outcome.ignored); + expect(await b.storage.receivedCards.all(), isEmpty); + }); + }); + + group('충돌 규칙', () { + test('같은 사람은 늦은 변경이 이기고, 다른 사람끼리는 먼저 쓴 변경이 이김', () async { + final rule = ConflictRule(Storage.memory().meta); + expect(await rule.accept('k', 'A', 100), true); + expect(await rule.accept('k', 'A', 150), true); + expect(await rule.accept('k', 'A', 120), false); + expect(await rule.accept('k', 'B', 200), false); + expect(await rule.accept('k', 'B', 100), true); + expect(await rule.accept('k', 'A', 300), false); + }); + }); + + group('시계 보정', () { + test('서버 시각으로 맞추고, 그 뒤에는 상대 시각으로 바꾸지 않음', () async { + final clock = Clock(); + final sync = ClockSync(clock); + final raw = DateTime.now().millisecondsSinceEpoch ~/ 10; + expect(await sync.syncWith(_Server(raw + 500)), true); + expect((clock.now() - raw - 500).abs() < 50, true); + expect(sync.syncWithPeer(raw + 9000), false); + expect(sync.source, 'server'); + }); + + test('상대 시각과 1초 미만 차이는 맞추지 않음 (전송 지연일 수 있음)', () async { + final clock = Clock(); + final sync = ClockSync(clock); + final raw = DateTime.now().millisecondsSinceEpoch ~/ 10; + expect(sync.syncWithPeer(raw + 30), false); + expect(clock.offset, 0); + }); + + test('서버가 없으면 같은 네트워크 상대 시각을 따름', () async { + final clock = Clock(); + final sync = ClockSync(clock); + expect(await sync.syncWith(_Server(null)), false); + final raw = DateTime.now().millisecondsSinceEpoch ~/ 10; + expect(sync.syncWithPeer(raw - 300), true); + expect((clock.now() - raw + 300).abs() < 50, true); + expect(sync.source, 'peer'); + }); + }); +} + +class _Server implements TimeSource { + _Server(this.stamp); + final int? stamp; + @override + Future referenceStamp() async => stamp; +} diff --git a/test/transport_test.dart b/test/transport_test.dart new file mode 100644 index 0000000..fe95a97 --- /dev/null +++ b/test/transport_test.dart @@ -0,0 +1,231 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'support/envelopes.dart'; + +const a = '+821011112222'; +const b = '+821022223333'; + +Future> collect(Transport t, Future Function() act) async { + final got = []; + final sub = t.incoming.listen(got.add); + await act(); + await Future.delayed(const Duration(milliseconds: 50)); + await sub.cancel(); + return got; +} + +/// 보관함 서버를 띄운다. 윈도우의 python3 는 스토어 바로가기일 수 있어 python, py 순으로도 시도한다. +Future<(Process, String)> startServer(List extra) async { + for (final exe in ['python3', 'python', 'py']) { + Process? p; + try { + p = await Process.start(exe, ['mailbox_server.py', '--host', '127.0.0.1', '--port', '0', ...extra], + workingDirectory: 'server'); + final line = await p.stdout.transform(utf8.decoder).transform(const LineSplitter()).first + .timeout(const Duration(seconds: 5)); + return (p, 'http://127.0.0.1:${line.split(' ').last}'); + } catch (_) { + p?.kill(); + } + } + throw StateError('파이썬을 찾지 못함'); +} + +void main() { + group('메모리 연결', () { + test('켜져 있으면 바로 도착', () async { + final hub = MemoryHub(); + final ta = MemoryTransport(a, hub), tb = MemoryTransport(b, hub); + await ta.start(); + await tb.start(); + final got = await collect(tb, () async => expect(await ta.send(envelope('1')), true)); + expect(got.single.id, '1'); + }); + + test('꺼져 있으면 보관함에 쌓였다가 가져옴', () async { + final hub = MemoryHub(); + final ta = MemoryTransport(a, hub), tb = MemoryTransport(b, hub); + await ta.start(); + expect(await ta.send(envelope('1')), true); + await tb.start(); + final got = await collect(tb, () async => expect(await tb.fetchPending(), 1)); + expect(got.single.id, '1'); + expect(await tb.fetchPending(), 0); + }); + + test('보관함을 끄면 못 보냄', () async { + final hub = MemoryHub()..mailboxEnabled = false; + final ta = MemoryTransport(a, hub); + MemoryTransport(b, hub); + await ta.start(); + expect(await ta.send(envelope('1')), false); + }); + }); + + group('묶음 연결', () { + test('앞 방식이 못 보내면 다음 방식으로 보냄', () async { + final hub1 = MemoryHub()..mailboxEnabled = false; + final hub2 = MemoryHub(); + final first = MemoryTransport(a, hub1); + final second = MemoryTransport(a, hub2); + final tb = MemoryTransport(b, hub2); + final c = CompositeTransport([first, second]); + await c.start(); + await tb.start(); + MemoryTransport(b, hub1); + final got = await collect(tb, () async => expect(await c.send(envelope('1')), true)); + expect(got.single.id, '1'); + }); + + test('모두 못 보내면 false, 받은 것은 하나로 합쳐 줌', () async { + final hub = MemoryHub()..mailboxEnabled = false; + final t = MemoryTransport(a, hub); + final c = CompositeTransport([t]); + await c.start(); + expect(await c.send(envelope('1')), false); + final peer = MemoryTransport(b, hub); + await peer.start(); + final got = await collect(c, () async => await peer.send(envelope('2', from: b, to: a))); + expect(got.single.id, '2'); + }); + }); + + group('직접 연결 (같은 네트워크)', () { + test('주소를 알면 바로 보내고, 모르면 false', () async { + DirectAddress? where; + final recv = DirectTransport(resolve: (_) async => null); + final send = DirectTransport(resolve: (_) async => where); + await recv.start(); + await send.start(); + expect(await send.send(envelope('1')), false); + where = DirectAddress('127.0.0.1', recv.boundPort); + final got = await collect(recv, () async => expect(await send.send(envelope('2')), true)); + expect(got.single.id, '2'); + await recv.stop(); + await send.stop(); + }); + }); + + group('외부 보관함 연결', () { + Process? server; + String? base; + + setUpAll(() async { + try { + (server, base) = await startServer([]); + } catch (_) { + server = null; // 파이썬이 없으면 이 묶음은 건너뜀 + } + }); + tearDownAll(() => server?.kill()); + + test('보내고, 가져오고, 지움', () async { + if (server == null) return; + final ta = MailboxTransport(baseUrl: base!, myPhone: a, token: 'TA'); + final tb = MailboxTransport(baseUrl: base!, myPhone: b, token: 'TB'); + expect(await ta.send(envelope('1')), true); + final got = await collect(tb, () async => expect(await tb.fetchPending(), 1)); + expect(got.single.body, 'SEALED'); + expect(await tb.fetchPending(), 0); // 받았다고 알렸으니 사라짐 + await ta.stop(); + await tb.stop(); + }); + + test('다른 비밀값으로는 우편함을 못 염', () async { + if (server == null) return; + final first = MailboxTransport(baseUrl: base!, myPhone: '+821033334444', token: 'T1'); + final other = MailboxTransport(baseUrl: base!, myPhone: '+821033334444', token: 'T2'); + await first.fetchPending(); + expect(await other.fetchPending(), 0); + }); + + test('서버 시각을 알려 주고, 서버가 꺼져 있으면 보내기 실패', () async { + if (server == null) return; + final t = MailboxTransport(baseUrl: base!, myPhone: a, token: 'TA'); + expect((await t.referenceStamp())!, greaterThan(170000000000)); + final dead = MailboxTransport( + baseUrl: 'http://127.0.0.1:1', myPhone: a, token: 'TA', timeout: const Duration(seconds: 1)); + expect(await dead.send(envelope('1')), false); + expect(await dead.referenceStamp(), null); + }); + }); + + group('늦게 만드는 연결', () { + test('시작 전에는 보내기 실패, 시작하면 안쪽 연결로 전달', () async { + final hub = MemoryHub(); + final late = LateTransport(() async => MemoryTransport(a, hub)); + final tb = MemoryTransport(b, hub); + await tb.start(); + expect(await late.send(envelope('0')), false); + await late.start(); + final got = await collect(tb, () async => expect(await late.send(envelope('1')), true)); + expect(got.length, 1); + await late.stop(); + }); + + test('안쪽 연결이 없으면(null) 조용히 아무것도 안 함', () async { + final late = LateTransport(() async => null); + await late.start(); + expect(await late.send(envelope('1')), false); + expect(await late.fetchPending(), 0); + expect(await late.referenceStamp(), null); + }); + + test('받은 봉투를 바깥으로 흘려 준다', () async { + final hub = MemoryHub(); + final late = LateTransport(() async => MemoryTransport(a, hub)); + final tb = MemoryTransport(b, hub); + await late.start(); + await tb.start(); + final got = await collect(late, () async => expect(await tb.send(envelope('2', to: a)), true)); + expect(got.length, 1); + }); + }); + + group('문자 확인', () { + Process? server; + String? base; + late File smsLog; + final token = 'c' * 32; + + setUpAll(() async { + try { + smsLog = File('${Directory.systemTemp.createTempSync('fm_sms').path}/sms.log'); + (server, base) = await startServer(['--sms-log', smsLog.path]); + } catch (_) { + server = null; + } + }); + tearDownAll(() => server?.kill()); + + String lastCode() => RegExp(r'\b(\d{6})\b').allMatches(smsLog.readAsStringSync()).last.group(1)!; + + test('요청하면 문자가 가고, 맞는 번호를 넣으면 확인되고 틀리면 거절', () async { + if (server == null) return; + final v = MailboxVerifier(baseUrl: base!, myPhone: a, token: token); + expect(await v.requestCode(), VerifyStart.sent); + expect(await v.confirm('000000' == lastCode() ? '111111' : '000000'), false); + expect(await v.confirm(lastCode()), true); + }); + + test('1시간에 3번이 넘으면 잠시 막힘', () async { + if (server == null) return; + final v = MailboxVerifier(baseUrl: base!, myPhone: b, token: token); + for (var i = 0; i < 3; i++) { + expect(await v.requestCode(), VerifyStart.sent); + } + expect(await v.requestCode(), VerifyStart.rateLimited); + }); + + test('서버가 없으면 실패, 문자 확인을 안 쓰는 서버면 쓸 수 없음', () async { + expect(await MailboxVerifier(baseUrl: 'http://127.0.0.1:1', myPhone: a, token: token).requestCode(), VerifyStart.failed); + expect(await LatePhoneVerifier(() async => null).requestCode(), VerifyStart.unavailable); + expect(await LatePhoneVerifier(() async => null).confirm('123456'), false); + }); + }); +} diff --git a/test/verify_service_test.dart b/test/verify_service_test.dart new file mode 100644 index 0000000..a0a3d89 --- /dev/null +++ b/test/verify_service_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; + +import 'support/app_device.dart'; + +class FakeVerifier implements PhoneVerifier { + VerifyStart start = VerifyStart.sent; + String code = '123456'; + @override + Future requestCode() async => start; + @override + Future confirm(String c) async => c == code; +} + +void main() { + test('확인 수단이 없으면 쓸 수 없음', () async { + final d = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '가'); + expect(d.app.verify.available, false); + expect(await d.app.verify.request(), VerifyStart.unavailable); + expect(await d.app.verify.confirm('123456'), false); + expect(await d.app.verify.isVerified(), false); + }); + + test('틀린 번호는 확인되지 않고, 맞는 번호를 넣으면 확인됨으로 남는다', () async { + final v = FakeVerifier(); + final d = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '가', verifier: v); + expect(d.app.verify.available, true); + expect(await d.app.verify.request(), VerifyStart.sent); + expect(await d.app.verify.confirm('000000'), false); + expect(await d.app.verify.isVerified(), false); + expect(await d.app.verify.confirm('123456'), true); + expect(await d.app.verify.isVerified(), true); + }); + + test('요청 결과(너무 자주 등)를 그대로 알린다', () async { + final v = FakeVerifier()..start = VerifyStart.rateLimited; + final d = await AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '가', verifier: v); + expect(await d.app.verify.request(), VerifyStart.rateLimited); + }); +} diff --git a/test/visibility_service_test.dart b/test/visibility_service_test.dart new file mode 100644 index 0000000..22e156a --- /dev/null +++ b/test/visibility_service_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; +import 'package:friendly_map/modules/visibility/visibility.dart'; + +import 'support/app_device.dart'; + +const pa = '+821011112222'; +const pb = '+821022223333'; + +/// 가가 친구를 추가하고(필요하면 관계를 이어 두고) 개인 명함을 고친 뒤, 나가 수락하면 기본 공유로 가는 명함. +Future<(AppDevice, AppDevice)> scenario({String? relation}) async { + final hub = MemoryHub(); + final a = await AppDevice.create(hub, '010-1111-2222', pa, '가'); + final b = await AppDevice.create(hub, '010-2222-3333', pb, '나'); + final person = (await a.app.friends.add(b.phone, b.name))!; + if (relation != null) await a.app.network.addLink(person.id, relation, '시험'); + final mine = (await a.app.cards.mine()).firstWhere((c) => c.kind == CardKinds.personal); + await a.app.cards.edit(mine.id, fields: { + CardKeys.name: '가', + CardKeys.phone: '010-1234-5678', + CardKeys.birthday: '03-14', + CardKeys.company: '한빛', + }); + await AppDevice.settleAll(); + await b.app.friends.accept(a.phone); + await AppDevice.settleAll(); + return (a, b); +} + +Future> received(AppDevice b, AppDevice a) async { + final aPerson = (await b.app.friends.people()).firstWhere((p) => p.phone == a.phone); + final got = await b.app.network.receivedCards(aPerson.id); + return decodeFields(got.first.fieldsJson); +} + +void main() { + test('관계를 모르는 친구에게는 내가 고른 그대로 간다', () async { + final (a, b) = await scenario(); + final got = await received(b, a); + expect(got[CardKeys.phone], '010-1234-5678'); + expect(got[CardKeys.birthday], '03-14'); + }); + + test('그룹 관계인 친구에게는 생일·연락처·회사가 가지 않는다', () async { + final (a, b) = await scenario(relation: '같은 모임'); + final got = await received(b, a); + expect(got[CardKeys.name], '가'); + expect(got.containsKey(CardKeys.phone), false); + expect(got.containsKey(CardKeys.birthday), false); + expect(got.containsKey(CardKeys.company), false); + }); + + test('가족 관계인 친구에게는 전부 간다', () async { + final (a, b) = await scenario(relation: '가족:동생'); + final got = await received(b, a); + expect(got[CardKeys.phone], '010-1234-5678'); + expect(got[CardKeys.birthday], '03-14'); + }); + + test('표의 칸을 돌리면 저장되고, 처음으로 되돌릴 수 있다', () async { + final (a, _) = await scenario(); + expect((await a.app.visibility.matrix()).get(VCategory.birthday, VAudience.group), VLevel.hidden); + final after = await a.app.visibility.cycle(VCategory.birthday, VAudience.group); + expect(after.get(VCategory.birthday, VAudience.group), VLevel.open); + expect((await a.app.visibility.matrix()).get(VCategory.birthday, VAudience.group), VLevel.open); + await a.app.visibility.reset(); + expect((await a.app.visibility.matrix()).get(VCategory.birthday, VAudience.group), VLevel.hidden); + }); + + test('표를 고쳐도 이미 보낸 명함은 "다시 보내기" 전까지 그대로, 다시 보내면 새 표가 적용', () async { + final (a, b) = await scenario(relation: '같은 모임'); + await a.app.visibility.cycle(VCategory.contact, VAudience.group); // 숨김 → 공개 + await AppDevice.settleAll(); + expect((await received(b, a)).containsKey(CardKeys.phone), false); + final mine = (await a.app.cards.mine()).firstWhere((c) => c.kind == CardKinds.personal); + final share = (await a.app.cards.sharesOf(mine.id)).single; + expect(await a.app.cards.resend(share.id), true); + await AppDevice.settleAll(); + expect((await received(b, a))[CardKeys.phone], '010-1234-5678'); + }); +} diff --git a/test/visibility_test.dart b/test/visibility_test.dart new file mode 100644 index 0000000..7392ddc --- /dev/null +++ b/test/visibility_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/core/core.dart'; +import 'package:friendly_map/modules/visibility/visibility.dart'; + +void main() { + final all = { + CardKeys.name: '이미영', + CardKeys.phone: '010-1234-5678', + CardKeys.email: 'miyoung@mail.com', + CardKeys.birthday: '03-14', + CardKeys.company: '한빛소프트', + CardKeys.position: '팀장', + CardKeys.school: '한국대', + CardKeys.year: '2008', + }; + + group('표', () { + test('처음 값: 가족은 모두 공개, 그룹은 숨김', () { + final m = VisibilityMatrix.defaults(); + for (final c in VCategory.all) { + expect(m.get(c, VAudience.family), VLevel.open); + } + expect(m.get(VCategory.birthday, VAudience.group), VLevel.hidden); + expect(m.get(VCategory.work, VAudience.school), VLevel.partial); + }); + + test('칸을 바꾸고 글자로 저장했다가 복원, 깨진 글자는 처음 값', () { + final m = VisibilityMatrix.defaults().with_(VCategory.birthday, VAudience.group, VLevel.open); + final back = VisibilityMatrix.fromJson(m.toJson()); + expect(back.get(VCategory.birthday, VAudience.group), VLevel.open); + expect(back.get(VCategory.school, VAudience.family), VLevel.open); + expect(VisibilityMatrix.fromJson('{깨짐').get(VCategory.birthday, VAudience.group), VLevel.hidden); + expect(VisibilityMatrix.fromJson(null).get(VCategory.contact, VAudience.company), VLevel.partial); + }); + + test('단계는 공개→일부→숨김→공개로 돈다', () { + expect(VLevel.open.next, VLevel.partial); + expect(VLevel.partial.next, VLevel.hidden); + expect(VLevel.hidden.next, VLevel.open); + }); + + test('여러 관계면 가장 많이 보여 주는 단계', () { + final m = VisibilityMatrix.defaults(); + expect(m.forAudiences(VCategory.birthday, [VAudience.group, VAudience.company]), VLevel.partial); + expect(m.forAudiences(VCategory.birthday, [VAudience.group, VAudience.family]), VLevel.open); + }); + }); + + group('관계 찾기', () { + test('연결 관계 글자와 명함 종류에서 관계 묶음', () { + expect(VAudience.ofRelation('가족:어머니'), VAudience.family); + expect(VAudience.ofRelation('전 직장 동료'), VAudience.company); + expect(VAudience.ofRelation('동창'), VAudience.school); + expect(VAudience.ofRelation('같은 여행'), VAudience.group); + expect(VAudience.ofRelation('이웃'), null); + expect(VAudience.ofCardKind(CardKinds.company), VAudience.company); + expect(VAudience.ofCardKind(CardKinds.personal), null); + }); + }); + + group('걸러 내기', () { + final m = VisibilityMatrix.defaults(); + + test('관계를 모르는 사람에게는 내가 고른 그대로', () { + expect(applyVisibility(all, {}, m), all); + }); + + test('가족에게는 전부', () { + expect(applyVisibility(all, {VAudience.family}, m), all); + }); + + test('그룹 사람에게는 이름만 남고 나머지는 숨김', () { + final out = applyVisibility(all, {VAudience.group}, m); + expect(out, {CardKeys.name: '이미영'}); + }); + + test('회사 사람: 연락처·생일은 일부, 학교·회사는 공개', () { + final out = applyVisibility(all, {VAudience.company}, m); + expect(out[CardKeys.phone], '****5678'); + expect(out[CardKeys.email], 'm***@mail.com'); + expect(out[CardKeys.birthday], '3월'); + expect(out[CardKeys.company], '한빛소프트'); + expect(out[CardKeys.position], '팀장'); + expect(out[CardKeys.school], '한국대'); + }); + + test('동문 사람: 회사는 이름만(직책 빠짐), 생일·연락처 숨김', () { + final out = applyVisibility(all, {VAudience.school}, m); + expect(out[CardKeys.company], '한빛소프트'); + expect(out.containsKey(CardKeys.position), false); + expect(out.containsKey(CardKeys.birthday), false); + expect(out.containsKey(CardKeys.phone), false); + expect(out[CardKeys.school], '한국대'); + }); + + test('원본은 바뀌지 않는다', () { + applyVisibility(all, {VAudience.group}, m); + expect(all.length, 8); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9b3ecb2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:friendly_map/modules/app/app.dart'; +import 'package:friendly_map/modules/transport/transport.dart'; +import 'package:friendly_map/modules/ui/ui.dart'; + +import 'support/app_device.dart'; + +/// 키·저장소를 어떻게 여는지는 화면이 모른다. 시험에서는 메모리 기기로 대신한다. +class FakeBootstrap implements Bootstrap { + FakeBootstrap({this.keys = false, this.app}); + + bool keys; + final FriendlyApp? app; + + @override + Future hasKeys() async => keys; + @override + Future createKeys() async { + keys = true; + return 'one two three four five six seven eight nine ten eleven twelve'; + } + + @override + Future restoreKeys(String phrase) async => false; + @override + Future> splitRecovery(int n, int k) async => [for (var i = 1; i <= n; i++) 'fmr1.$k.0000.$i:00']; + @override + Future openApp() async => app!; + @override + Future recoveryPhrase() async => null; +} + +void main() { + testWidgets('키가 없으면 처음 화면이 보인다', (tester) async { + await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap())); + await tester.pumpAndSettle(); + expect(find.text('새로 시작하기'), findsOneWidget); + }); + + testWidgets('내 정보가 있으면 홈과 아래 메뉴가 보이고 칸을 옮길 수 있다', (tester) async { + final device = await tester.runAsync(() => AppDevice.create(MemoryHub(), '010-1111-2222', '+821011112222', '이미영', start: false)); + await tester.pumpWidget(FriendlyMapApp(bootstrap: FakeBootstrap(keys: true, app: device!.app))); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + await tester.tap(find.descendant(of: find.byType(NavigationBar), matching: find.text('명함'))); + await tester.pumpAndSettle(); + expect(find.text('내 명함'), findsWidgets); + }); +} diff --git a/tool/android_patch.py b/tool/android_patch.py new file mode 100644 index 0000000..67bdeb5 --- /dev/null +++ b/tool/android_patch.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""안드로이드 설정 한 번에 맞추기. + +사용: flutter create --platforms=android --org com.friendlymap --project-name friendly_map . + python3 tool/android_patch.py +권한(인터넷, 카메라, 같은 네트워크 발견), 평문 통신(같은 네트워크 직접 연결), minSdk 23 이상을 맞춘다. +여러 번 실행해도 같은 결과가 나온다. +""" +import pathlib, re, sys + +root = pathlib.Path(__file__).resolve().parent.parent / "android" / "app" +manifest = root / "src" / "main" / "AndroidManifest.xml" +MIN_SDK = 23 +PERMS = [ + "android.permission.INTERNET", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.ACCESS_WIFI_STATE", + "android.permission.CHANGE_WIFI_MULTICAST_STATE", + "android.permission.CAMERA", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.READ_CALL_LOG", + "android.permission.POST_NOTIFICATIONS", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.VIBRATE", +] +RECEIVERS = """ + + + + + +""" + + +def patch_manifest(text: str) -> str: + add = "".join(f' \n' for p in PERMS if p not in text) + if add: + text = re.sub(r"(]*>\s*\n)", lambda m: m.group(1) + add, text, count=1) + if "usesCleartextTraffic" not in text: + text = text.replace("", RECEIVERS + " ", 1) + return text + + +def patch_gradle(text: str) -> str: + # minSdk 는 23 이상이면 된다. flutter.minSdkVersion(현재 Flutter 는 24)은 그대로 둔다. + # Flutter 는 빌드할 때 자기 최소값보다 낮은 숫자를 flutter.minSdkVersion 으로 되돌리므로 낮추지 않는다. + text = re.sub(r"(minSdk\s*=\s*|minSdkVersion\s+)(\d+)", + lambda m: m.group(1) + str(max(int(m.group(2)), MIN_SDK)), text) + # 예약 알림 플러그인이 요구하는 자바 새 기능 지원(desugaring) + if "isCoreLibraryDesugaringEnabled" not in text and "coreLibraryDesugaringEnabled" not in text: + if "compileOptions {" in text: + flag = "isCoreLibraryDesugaringEnabled = true" if "kotlin" in text or "=" in text.split("compileOptions {")[1][:80] else "coreLibraryDesugaringEnabled true" + text = text.replace("compileOptions {", "compileOptions {\n " + flag, 1) + if "coreLibraryDesugaring(" not in text and "coreLibraryDesugaring '" not in text: + kts = "isCoreLibraryDesugaringEnabled" in text + dep = 'coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")' if kts else "coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'" + text += "\ndependencies {\n " + dep + "\n}\n" + return text + + +def main() -> int: + if not manifest.exists(): + print("android/ 가 비어 있어요. 먼저 flutter create --platforms=android ... . 를 실행해 주세요.") + return 1 + manifest.write_text(patch_manifest(manifest.read_text(encoding="utf-8")), encoding="utf-8") + for name in ("build.gradle.kts", "build.gradle"): + g = root / name + if g.exists(): + g.write_text(patch_gradle(g.read_text(encoding="utf-8")), encoding="utf-8") + print("안드로이드 설정을 맞췄어요.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tool/check_modules.py b/tool/check_modules.py new file mode 100644 index 0000000..8e25742 --- /dev/null +++ b/tool/check_modules.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""모듈 규칙 검사: (1) 파일 700줄 이하 (400줄 넘으면 경고) (2) 모듈 간 import 방향.""" +import os, re, sys, pathlib + +MAX, WARN = 700, 400 +# 허용 방향: 왼쪽 모듈은 오른쪽 모듈만 import 할 수 있다. +ALLOW = { + "core": set(), + "platform": set(), + "platform_android": {"core", "platform", "storage"}, + "storage": {"platform"}, + "keys": {"core", "platform"}, + "sharing": {"storage"}, + "sync": {"core", "storage"}, + "transport": {"core"}, + "discovery": {"platform"}, + "inference": {"core"}, + "layout": {"core"}, + "visibility": {"core"}, + "kinship": set(), + "events": set(), + "app": {"core", "platform", "storage", "sharing", "sync", "discovery", "inference", "layout", "visibility", "kinship", "events"}, + "ui": {"app", "core", "platform", "storage", "discovery", "inference", "layout", "visibility", "kinship", "events"}, +} +# 모듈 입구가 둘 이상인 경우: 입구 파일이 모듈 이름.dart 외에 허용되는 것들. +EXTRA_BARRELS = {"platform": {"platform_ui.dart"}, "storage": {"storage_sqlite.dart"}} +# 모듈 폴더 밖(main.dart, composition/)은 조립 코드라서 어느 모듈 입구든 쓸 수 있다. +# 플랫폼마다 다른 플러그인은 platform_android 에서만 쓴다 (나중에 iOS 등을 붙이기 쉽게). +PLATFORM_ONLY = ("flutter_secure_storage", "sensors_plus", "nsd", "mobile_scanner", "sqflite_sqlcipher", "geolocator", "call_log", "url_launcher", "flutter_local_notifications", "timezone") +root = pathlib.Path(__file__).resolve().parent.parent / "lib" +bad = 0 +for f in sorted(root.rglob("*.dart")): + n = len(f.read_text(encoding="utf-8").splitlines()) + rel = f.relative_to(root) + if n > MAX: + print(f"오류 {rel}: {n}줄 (최대 {MAX})"); bad += 1 + elif n > WARN: + print(f"경고 {rel}: {n}줄 ({WARN}줄 넘음)") + parts = rel.parts + in_platform = len(parts) >= 2 and parts[1].startswith("platform_") + for m in re.finditer(r"import\s+'package:(\w+)/", f.read_text(encoding="utf-8")): + if m.group(1) in PLATFORM_ONLY and not in_platform: + print(f"오류 {rel}: {m.group(1)} 는 platform_* 모듈에서만 import"); bad += 1 + if len(parts) >= 3 and parts[0] == "modules": + me = parts[1] + for m in re.finditer(r"import\s+'([^']+)'", f.read_text(encoding="utf-8")): + p = m.group(1) + if p.startswith("package:friendly_map/"): + target = pathlib.PurePosixPath(p[len("package:friendly_map/"):]) + elif p.startswith("."): + target = pathlib.PurePosixPath( + os.path.normpath(os.path.join(os.path.dirname(rel.as_posix()), p)).replace(os.sep, "/")) + else: + continue + tp = target.parts + if len(tp) >= 3 and tp[0] == "modules" and tp[1] != me: + other = tp[1] + if other not in ALLOW.get(me, set()): + print(f"오류 {rel}: {me} 모듈은 {other} 모듈을 쓸 수 없음"); bad += 1 + elif target.as_posix() not in {f"modules/{other}/{other}.dart", *(f"modules/{other}/{x}" for x in EXTRA_BARRELS.get(other, ()))}: + print(f"오류 {rel}: {other} 모듈은 {other}.dart 로만 import"); bad += 1 +print("통과" if not bad else f"규칙 위반 {bad}건") +sys.exit(1 if bad else 0) diff --git a/tool/test_module.sh b/tool/test_module.sh new file mode 100644 index 0000000..ea8399f --- /dev/null +++ b/tool/test_module.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# 모듈 하나만 시험한다: tool/test_module.sh <모듈이름> (인자 없으면 목록) +# 시험이 끝난 모듈은 다시 돌릴 필요 없이 그 위에 쌓는다. 전체는 `flutter test`. +set -euo pipefail +cd "$(dirname "$0")/.." +declare -A T=( + [keys]="keys_test.dart keys_crypto_test.dart shamir_test.dart recovery_shares_test.dart" + [visibility]="visibility_test.dart" + [kinship]="kinship_test.dart" + [events]="events_test.dart lunar_test.dart" + [storage]="storage_memory_test.dart storage_sqlite_test.dart storage_models_test.dart" + [transport]="transport_test.dart" + [verify]="verify_service_test.dart family_gathering_test.dart" + [sync]="sync_test.dart" + [discovery]="discovery_test.dart" + [inference]="inference_test.dart" + [layout]="layout_test.dart" + [sharing]="sharing_service_test.dart sharing_status_test.dart" + [app]="app_test.dart archive_advice_test.dart visibility_service_test.dart recovery_service_test.dart location_test.dart verify_service_test.dart" + [platform_android]="df_parser_test.dart" + [ui]="widget_test.dart" +) +if [ $# -ne 1 ] || [ -z "${T[$1]:-}" ]; then + echo "모듈: ${!T[*]}"; exit 2 +fi +args=(); for f in ${T[$1]}; do args+=("test/$f"); done +exec flutter test "${args[@]}"