diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
new file mode 100644
index 00000000..3880a0d9
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE.md
@@ -0,0 +1,2 @@
+## Overview (Required)
+-
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..380bffd4
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,13 @@
+## Issue
+- close #ISSUE_NUMBER
+
+## Overview (Required)
+-
+
+## Links
+-
+
+## Screenshot
+Before | After
+:--: | :--:
+ |
diff --git a/.github/workflows/develop-branch-review.yml b/.github/workflows/develop-branch-review.yml
new file mode 100644
index 00000000..0d18f5f8
--- /dev/null
+++ b/.github/workflows/develop-branch-review.yml
@@ -0,0 +1,36 @@
+name: Develop Branch CI
+
+on:
+ push:
+ branches: [ develop ]
+ pull_request:
+ branches: [ develop ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: set up JDK 11
+ uses: actions/setup-java@v2
+ with:
+ java-version: '11'
+ distribution: 'adopt'
+ cache: gradle
+
+ - name: Decrypt Services
+ run: gpg --quiet --batch --yes --always-trust --decrypt --passphrase="$SECRET_SERVICE_TAR_GPG" --output ./app/services.tar ./app/services.tar.gpg
+ env:
+ SECRET_SERVICE_TAR_GPG: ${{ secrets.SECRET_SERVICE_TAR_GPG }}
+
+ - name: Unzip Services
+ run: tar xvf ./app/services.tar -C ./app
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+ - name: Build with Gradle
+ run: ./gradlew build
+ - name: Run Unit Test
+ run: ./gradlew testDebugUnitTest
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..9750219b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,182 @@
+*.iml
+.gradle
+/local.properties
+.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/google-services.json
+
+# Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio
+# Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio
+
+### Android ###
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
+# Android Profiling
+*.hprof
+
+### Android Patch ###
+gen-external-apklibs
+output.json
+
+# Replacement of .externalNativeBuild directories introduced
+# with Android Studio 3.5.
+
+### AndroidStudio ###
+# Covers files to be ignored for android development using Android Studio.
+
+# Built application files
+
+# Files for the ART/Dalvik VM
+
+# Java class files
+
+# Generated files
+
+# Signing files
+.signing/
+
+# Local configuration file (sdk path, etc)
+
+# Proguard folder generated by Eclipse
+
+# Log Files
+
+# Android Studio
+/*/build/
+/*/local.properties
+/*/out
+/*/*/build
+/*/*/production
+*.ipr
+*~
+*.swp
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Android Patch
+
+# External native build folder generated in Android Studio 2.2 and later
+
+# NDK
+obj/
+
+# IntelliJ IDEA
+*.iws
+/out/
+
+# OS-specific files
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Legacy Eclipse project files
+.classpath
+.project
+.cproject
+.settings/
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.war
+*.ear
+
+# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
+hs_err_pid*
+
+## Plugin-specific files:
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### AndroidStudio Patch ###
+
+!/gradle/wrapper/gradle-wrapper.jar
+
+# End of https://www.toptal.com/developers/gitignore/api/android,androidstudio
\ No newline at end of file
diff --git a/README.md b/README.md
index 94e05464..2a527097 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,54 @@
-# android03-IvyClub
-안드로이드 그룹 프로젝트 3조 아이비클럽 저장소입니다.
+
컨택
+
+
+
+
+
+
+
+ReadMe in English
+Contact는 안드로이드 기반의 지인 관리 애플리케이션입니다.
+
+
+
+
+
+
+## Previews
+
+
+## 제공되는 기능
+- 친구와 지인의 정보를 기록하고 저장할 수 있는 **컨택트**입니다.
+- 여러분들의 소중한 파트너를 기록해보세요. 어떤 정보라도 좋습니다.
+- 지인 정보를 기록함으로써 지인을 기억하고 다음 만남에서 더 자연스럽게 대화를 이어나갈 수 있도록 도와드릴게요.
+- 이뿐만 아니라 지인과의 약속을 저장해주시면 기억하고 있다가 해당 약속을 알림으로 알려드릴게요.
+- 컨택을 보호하기 위해 비밀번호와 지문인식 기능을 이용할 수 있어요. 걱정하지 마세요.
+
+## 기술 스택
+- Minimum SDK level 21로 98% 이상의 안드로이드 디바이스 지원
+- 100% [Kotlin](https://kotlinlang.org/) 기반 + [Coroutines](https://developer.android.com/kotlin/coroutines) + [Flow](https://developer.android.com/kotlin/flow)
+- [Jetpack](https://developer.android.com/jetpack)
+ - ViewModel
+ - Room Persistence
+- Architecture
+ - MVVM Architecture ( View - Databinding - ViewModel - Model )
+ - Repository Pattern
+- DI를 위한 [Hilt](https://developer.android.com/training/dependency-injection/hilt-android)
+- 지문인식을 위한 [Biometric](https://developer.android.com/jetpack/androidx/releases/biometric)
+- 알림을 위한 [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)
+- 이미지 처리를 위한 [Glide](https://github.com/bumptech/glide)
+- 온보딩 애니메이션을 위한 [Lottie](https://airbnb.io/lottie/#/)
+- Unit test를 위한 [JUnit 4](https://github.com/junit-team/junit4)
+- CI를 위한 [Github Actions](https://github.com/boostcampwm-2021/android03-Contact/tree/develop/.github/workflows)
+- 한국어, 영어, 중국어 지원
+
+## 릴리즈
+이번 겨울 구글 플레이 스토어에서 만나요.
+
+## MAD Scorecard
+
+
+
+
+## 연락처
+email : bcivyclub@gmail.com
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 00000000..2b6bb7ca
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,89 @@
+plugins {
+ id("com.android.application")
+ kotlin("android")
+ kotlin("kapt")
+ id("kotlin-android")
+ id("androidx.navigation.safeargs.kotlin")
+ id("dagger.hilt.android.plugin")
+ id("com.google.gms.google-services")
+ id("com.google.android.gms.oss-licenses-plugin")
+ id("com.google.firebase.crashlytics")
+}
+
+android {
+ defaultConfig {
+ applicationId = "com.ivyclub.contact"
+ versionCode = Apps.versionCode
+ versionName = Apps.versionName
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+
+ lint {
+ isAbortOnError = false
+ }
+ }
+ buildFeatures {
+ viewBinding = true
+ dataBinding = true
+ }
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+dependencies {
+ implementation(Dep.AndroidX.core)
+ implementation(Dep.AndroidX.appcompat)
+ implementation(Dep.AndroidX.material)
+ implementation(Dep.AndroidX.constraintLayout)
+ implementation(Dep.AndroidX.navigationUIKtx)
+ implementation(Dep.AndroidX.legacySupport)
+ implementation(Dep.AndroidX.navigationFragmentKtx)
+ implementation(Dep.AndroidX.activityKtx)
+ implementation(Dep.AndroidX.fragmentKtx)
+ implementation(Dep.AndroidX.viewpager2)
+ implementation(Dep.AndroidX.support)
+ implementation(Dep.AndroidX.liveDataKtx)
+ implementation(Dep.AndroidX.lifecycleRuntimeKtx)
+ implementation(Dep.AndroidX.biometric)
+ implementation(Dep.AndroidX.workRuntime)
+ implementation(Dep.AndroidX.hiltWork)
+ implementation(Dep.Kotlin.coroutine)
+ implementation(Dep.Kotlin.coroutineCore)
+ implementation(Dep.Libs.glide)
+ implementation(Dep.Libs.gson)
+ implementation(Dep.Libs.flexboxLayout)
+ implementation(Dep.Libs.hilt)
+ implementation(Dep.Libs.hiltViewModel)
+ implementation(Dep.Libs.indicator)
+ implementation(Dep.Libs.lottie)
+ implementation(Dep.Libs.jBCrypt)
+ implementation(Dep.Libs.ossLicensesLibrary)
+ implementation(platform(Dep.Firebase.firebaseBom))
+ implementation(Dep.Firebase.crashlyticsKtx)
+ implementation(Dep.Firebase.analyticsKtx)
+ implementation(project(mapOf("path" to ":data")))
+ kapt(Dep.AndroidX.roomCompiler)
+ kapt(Dep.Libs.hiltCompiler)
+ kapt(Dep.Libs.hiltViewModelCompiler)
+ kapt(Dep.Libs.hiltWorkCompiler)
+ testImplementation(Dep.Test.jUnit)
+ testImplementation(Dep.Test.mockito)
+ testImplementation(Dep.Test.coroutines)
+ testImplementation(Dep.Test.mockitoInline)
+ debugImplementation(Dep.Libs.leakCanary)
+ androidTestImplementation(Dep.Test.ext)
+ androidTestImplementation(Dep.Test.espresso)
+ androidTestImplementation(Dep.Test.hilt)
+ androidTestImplementation(Dep.Test.core)
+ androidTestImplementation(Dep.Test.contrib)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..ff59496d
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/services.tar.gpg b/app/services.tar.gpg
new file mode 100644
index 00000000..64418099
Binary files /dev/null and b/app/services.tar.gpg differ
diff --git a/app/src/androidTest/java/com/ivyclub/contact/FriendListFragmentTest.kt b/app/src/androidTest/java/com/ivyclub/contact/FriendListFragmentTest.kt
new file mode 100644
index 00000000..8a8b0d05
--- /dev/null
+++ b/app/src/androidTest/java/com/ivyclub/contact/FriendListFragmentTest.kt
@@ -0,0 +1,71 @@
+package com.ivyclub.contact
+
+import androidx.lifecycle.Lifecycle
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.launchActivity
+import androidx.test.espresso.Espresso.closeSoftKeyboard
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.typeText
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.contrib.RecyclerViewActions
+import androidx.test.espresso.matcher.ViewMatchers.*
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.ivyclub.contact.ui.main.MainActivity
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class FriendListFragmentTest {
+ private lateinit var scenario: ActivityScenario
+ private val friend1Name = "john"
+
+ @Before
+ fun setup() {
+ scenario = launchActivity()
+ scenario.moveToState(Lifecycle.State.RESUMED)
+ }
+
+ @Test
+ fun launchSettingsFragment() {
+ onView(withId(R.id.iv_settings_icon)).perform(click())
+ onView(withId(R.id.tv_get_contacts)).check(matches(withText(R.string.fragment_settings_get_contacts)))
+ }
+
+ // contact와 gildong이라는 이름을 넣어 친구가 추가되는지 확인
+ @Test
+ fun addFriend() {
+ addFriendLogic(friend1Name)
+ }
+
+ // gildong이라는 친구 추가하고,
+ // 검색했을 때 gildong이라는 친구가 나오는지 확인
+ @Test
+ fun addFriendAndSearchFriend() {
+ addFriendLogic(friend1Name)
+ onView(withId(R.id.iv_search)).perform(click())
+ onView(withId(R.id.et_search)).perform(typeText(friend1Name))
+ onView(withId(R.id.rv_friend_list)).check(matches(hasDescendant(withText(friend1Name))))
+ }
+
+ // 친구 리스트 중 첫 친구 클릭하는 이벤트
+ @Test
+ fun clickFirstFriendInRecyclerView() {
+ onView(withId(R.id.rv_friend_list)).perform(
+ RecyclerViewActions.actionOnItemAtPosition(1, click())
+ )
+ }
+
+ private fun addFriendLogic(friendName: String) {
+ onView(withId(R.id.iv_add_friend_icon)).perform(click()) // plus 버튼 클릭
+ onView(withText(R.string.menu_add_friend)).perform(click()) // 친구 추가하기 버튼 클릭
+ onView(withId(R.id.et_name)).perform(typeText(friendName))
+ closeSoftKeyboard()
+ onView(withId(R.id.iv_save_icon)).perform(click()) // 체크 버튼 클릭
+ onView(withText(friendName)).check(matches(isDisplayed()))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..adaccacd
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/data.json b/app/src/main/assets/data.json
new file mode 100644
index 00000000..b79c72e2
--- /dev/null
+++ b/app/src/main/assets/data.json
@@ -0,0 +1 @@
+{"v":"5.7.4","fr":29.9700012207031,"ip":0,"op":90.0000036657751,"w":1080,"h":1920,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 13","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 11","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-27.5,7.5],[30,70],[-32,3]],"o":[[0,0],[0,0],[27.5,-7.5],[-30,-70],[32,-3]],"v":[[221,-105],[220.5,-33.75],[245.75,-23.25],[449,-117],[598,-179]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.227],"y":[0.987]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.3],"y":[0.983]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[19]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 10","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-154,19],[0,0]],"o":[[0,0],[0,0],[154,-19],[0,0]],"v":[[203.5,-83],[245.5,-82.75],[520,87],[689,-25]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[0.999]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.052],"y":[0.999]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[7]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 9","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[10.124,-32.621],[-4.75,-5.75],[-13,7.5],[-102,84],[0,0],[-75.801,52.021],[-56,-38],[-72,48]],"o":[[0,0],[-6.75,21.75],[4.75,5.75],[13,-7.5],[102,-84],[0,0],[102,-70],[56,38],[72,-48]],"v":[[187.5,-77],[142.5,-65.75],[149.25,-25.75],[189.25,-26.5],[230,262],[376,274],[444,268],[460,176],[700,268]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.028],"y":[1.006]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.135],"y":[1.007]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[12]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 8","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[178,-14],[56,-24],[48,-54],[103,-25],[0,0],[0,0],[0.75,-14],[-3.579,0],[-17,26.5]],"o":[[-75.16,5.911],[-56,24],[-48,54],[-103,25],[0,0],[0,0],[-0.75,14],[4,0],[17,-26.5]],"v":[[608,-338],[486,-258],[350,-320],[223,-237],[112.25,-53.5],[81,-53.75],[63.5,-39.75],[77.75,-21.5],[112.5,-42]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.201],"y":[0.852]},"o":{"x":[0.167],"y":[0]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.279],"y":[1.011]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[86]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Shape Layer 7","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-13.421,-28.061],[0,0],[-296,88],[0,0]],"o":[[0,0],[2.75,5.75],[0,0],[296,-88],[0,0]],"v":[[65.25,-76.25],[109.75,-70.75],[111.5,-18.5],[312,440],[912,324]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.132],"y":[0.962]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.221],"y":[0.956]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[8]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[-106,76],[128,14],[-76.25,-6.778],[0,0]],"o":[[0,0],[0,0],[106,-76],[-128,-14],[90,8],[0,0]],"v":[[0.5,-82.25],[43.5,-82.5],[212,-480],[354,-678],[436,-458],[586,-510]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.566],"y":[0.991]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.583],"y":[0.99]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[3]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[450,84],[0,0],[0,0],[-21.75,9]],"o":[[0,0],[-450,-84],[0,0],[0,0],[21.75,-9]],"v":[[912,-426],[394,-564],[18.5,-101.5],[18.5,-31.5],[43.5,-23.75]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.36],"y":[0.989]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":30.0000012219251,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.411],"y":[0.987]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":36.0000014663101,"s":[92.5]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-364,-8],[0,0],[0,0],[18.75,0.75],[2.25,-8.5]],"o":[[0,0],[364,8],[0,0],[0,0],[-18.75,-0.75],[-2.25,8.5]],"v":[[-866,-14],[-490,66],[-20.25,-17.25],[-20.25,-64],[-40.25,-84],[-69.25,-61.25]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":39.0000015885026,"s":[88]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":3,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-384,108],[0,0],[0,0]],"o":[[0,0],[384,-108],[0,0],[0,0]],"v":[[-621,-149],[-503,-217],[-69.75,-84.75],[-70.25,-17.75]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":26.0000010590017,"s":[93]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":36.0000014663101,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Shape Layer 12","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-31.5,3],[0,13],[377,-124],[61,36]],"o":[[0,0],[31.5,-3],[0,-13],[-377,124],[-61,-36]],"v":[[-155.5,-57.5],[-128,-21.5],[-101,-61.5],[-436,-80],[-557,37]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[13]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-25.5,-1],[-1,-9.5],[224,88],[-28,-7],[270,118],[0,0]],"o":[[0,0],[25.5,1],[1,9.5],[-224,-88],[16,4],[-270,-118],[0,0]],"v":[[-155.5,-56],[-129,-84.5],[-101,-57.5],[-298,258],[-255,126],[-368,332],[-670,76]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[0.991]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":30.0000012219251,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.103],"y":[0.99]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[100]},{"t":36.0000014663101,"s":[6]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":3,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-107,-16],[-103,-86],[21.151,-23.887],[54,1],[-168,-13],[1.531,-49.362],[0,0],[15.865,-2.709],[8.178,-16.358],[-3.5,-12.5],[-16,0.75],[0,0]],"o":[[107,16],[78.138,65.241],[-23.924,27.018],[-77.142,-1.429],[135.045,10.45],[-0.59,19.006],[0,0],[-15.866,2.709],[-2.25,4.5],[3.5,12.5],[21.999,-1.031],[0,0]],"v":[[-617,-502],[-278,-368],[-232.151,-182.113],[-338,-145],[-327,-283],[-156.469,-102.953],[-181.25,-101],[-211.884,-108.959],[-238,-88.75],[-239.25,-46],[-210.75,-21.5],[-178.25,-30.25]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1.024]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":30.0000012219251,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.087],"y":[1.03]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":36.0000014663101,"s":[87]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Shape Layer 14","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,960,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[1116,1972],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":13,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.556862745098,0.705882352941,0.603921568627,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[2,6],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":90.0000036657751,"st":0,"bm":0}],"markers":[]}
\ No newline at end of file
diff --git a/app/src/main/ic_contact_launcher-playstore.png b/app/src/main/ic_contact_launcher-playstore.png
new file mode 100644
index 00000000..0dfc714f
Binary files /dev/null and b/app/src/main/ic_contact_launcher-playstore.png differ
diff --git a/app/src/main/java/com/ivyclub/contact/MainApplication.kt b/app/src/main/java/com/ivyclub/contact/MainApplication.kt
new file mode 100644
index 00000000..5579e968
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/MainApplication.kt
@@ -0,0 +1,33 @@
+package com.ivyclub.contact
+
+import android.app.Application
+import androidx.hilt.work.HiltWorkerFactory
+import androidx.work.Configuration
+import com.ivyclub.contact.util.PixelRatio
+import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
+
+@HiltAndroidApp
+class MainApplication : Application(), Configuration.Provider {
+
+ @Inject
+ lateinit var workerFactory: HiltWorkerFactory
+
+ override fun getWorkManagerConfiguration() =
+ Configuration.Builder()
+ .setWorkerFactory(workerFactory)
+ .build()
+
+ override fun onCreate() {
+ super.onCreate()
+ initPixelRatio()
+ }
+
+ private fun initPixelRatio() {
+ pixelRatio = PixelRatio(this)
+ }
+
+ companion object {
+ lateinit var pixelRatio: PixelRatio
+ }
+}
diff --git a/app/src/main/java/com/ivyclub/contact/customview/CircleXImageView.kt b/app/src/main/java/com/ivyclub/contact/customview/CircleXImageView.kt
new file mode 100644
index 00000000..5a138247
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/customview/CircleXImageView.kt
@@ -0,0 +1,58 @@
+package com.ivyclub.contact.customview
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import androidx.appcompat.widget.AppCompatImageView
+import com.ivyclub.contact.util.dpToPixel
+import com.ivyclub.contact.util.dpToPixelFloat
+
+class CircleXImageView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null
+) : AppCompatImageView(context, attrs) {
+
+ private var bitmapPaint: Paint = Paint().apply {
+ isAntiAlias = true
+ color = backgroundTintList?.defaultColor ?: Color.BLACK
+ }
+
+ private val radius: Float by lazy {
+ if (width > height) height / 2f else width / 2f
+ }
+ private val rectPaint: Paint by lazy {
+ Paint().apply {
+ color = Color.WHITE
+ strokeWidth = 2.dpToPixelFloat
+ }
+ }
+ private val startXY: Float by lazy {
+ (radius * xVertexRatio).toFloat()
+ }
+
+ init {
+ val defaultPadding = 2.dpToPixel
+ setPadding(defaultPadding, defaultPadding, defaultPadding, defaultPadding)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ drawCircle(canvas)
+ drawX(canvas)
+ super.onDraw(canvas)
+ }
+
+ private fun drawCircle(canvas: Canvas) = with(canvas) {
+ drawCircle(radius, radius, radius, bitmapPaint)
+ }
+
+ private fun drawX(canvas: Canvas) = with(canvas) {
+ drawLine(startXY, startXY, width - startXY, height - startXY, rectPaint);
+ drawLine(width - startXY, startXY, startXY, height - startXY, rectPaint)
+ }
+
+ companion object {
+ private const val xVertexRatio = 0.484925
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/library/image_picker/ImagerPickerFragment.kt b/app/src/main/java/com/ivyclub/contact/library/image_picker/ImagerPickerFragment.kt
new file mode 100644
index 00000000..2d697b92
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/library/image_picker/ImagerPickerFragment.kt
@@ -0,0 +1,9 @@
+package com.ivyclub.contact.library.image_picker
+
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentImagePickerBinding
+import com.ivyclub.contact.util.BaseFragment
+
+class ImagerPickerFragment: BaseFragment(R.layout.fragment_image_picker) {
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/model/FriendListData.kt b/app/src/main/java/com/ivyclub/contact/model/FriendListData.kt
new file mode 100644
index 00000000..a35b9061
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/model/FriendListData.kt
@@ -0,0 +1,13 @@
+package com.ivyclub.contact.model
+
+import com.ivyclub.contact.util.FriendListViewType
+
+data class FriendListData(
+ val id: Long = -1, // pk
+ val phoneNumber: String = "", // 전화번호
+ val name: String = "", // 이름
+ var groupName: String = "", // 속한 그룹명
+ val viewType: FriendListViewType,
+ var isColored: Boolean = false,
+ val isFavoriteFriend: Boolean = false
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/model/PhoneContactData.kt b/app/src/main/java/com/ivyclub/contact/model/PhoneContactData.kt
new file mode 100644
index 00000000..7c405693
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/model/PhoneContactData.kt
@@ -0,0 +1,6 @@
+package com.ivyclub.contact.model
+
+data class PhoneContactData(
+ val name: String,
+ val phoneNumber: String
+)
diff --git a/app/src/main/java/com/ivyclub/contact/receivers/AlarmReceiver.kt b/app/src/main/java/com/ivyclub/contact/receivers/AlarmReceiver.kt
new file mode 100644
index 00000000..273ed8b3
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/receivers/AlarmReceiver.kt
@@ -0,0 +1,49 @@
+package com.ivyclub.contact.receivers
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.ACTION_ALARM
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.REMINDER_PLAN_ID
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.REMINDER_TEXT
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.REMINDER_TITLE
+import com.ivyclub.contact.service.plan_reminder.PlanReminderNotification
+import com.ivyclub.contact.util.getHour
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.AndroidEntryPoint
+import java.sql.Date
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class AlarmReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var repository: ContactRepository
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent == null) return
+
+ if (intent.action == ACTION_ALARM) {
+
+ val alarmState = repository.getNotificationState()
+ val startHour = repository.getStartAlarmHour()
+ val endHour = repository.getEndAlarmHour()
+ val currentHour = Date(System.currentTimeMillis()).getHour()
+
+ if (!alarmState || currentHour < startHour || currentHour >= endHour) return
+
+ val notiTitle = intent.getStringExtra(REMINDER_TITLE)
+ val notiText = intent.getStringExtra(REMINDER_TEXT)
+ val planId = intent.getLongExtra(REMINDER_PLAN_ID, -1L)
+
+ if (notiTitle != null && notiText != null && context != null) {
+ PlanReminderNotification.makePlanNotification(
+ context,
+ notiTitle,
+ notiText,
+ planId
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/receivers/BootReceiver.kt b/app/src/main/java/com/ivyclub/contact/receivers/BootReceiver.kt
new file mode 100644
index 00000000..c32e3c55
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/receivers/BootReceiver.kt
@@ -0,0 +1,39 @@
+package com.ivyclub.contact.receivers
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.ivyclub.contact.service.WidgetProvider
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker
+import com.ivyclub.contact.util.getNewTime
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.sql.Date
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class BootReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var repository: ContactRepository
+
+ @Inject
+ lateinit var reminderMaker: PlanReminderMaker
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+
+ if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val todayStart = Date(System.currentTimeMillis()).getNewTime(0, 0).time
+ repository.getPlanListAfter(todayStart).forEach { planData ->
+ reminderMaker.makePlanReminders(planData)
+ }
+ }
+
+ context?.let { WidgetProvider.sendRefreshBroadcast(it) }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/receivers/LanguageChangedReceiver.kt b/app/src/main/java/com/ivyclub/contact/receivers/LanguageChangedReceiver.kt
new file mode 100644
index 00000000..73877b1e
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/receivers/LanguageChangedReceiver.kt
@@ -0,0 +1,31 @@
+package com.ivyclub.contact.receivers
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.ivyclub.contact.R
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class LanguageChangedReceiver : BroadcastReceiver() {
+
+ @Inject
+ lateinit var repository: ContactRepository
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent == null || context == null) return
+
+ if (intent.action == Intent.ACTION_LOCALE_CHANGED) {
+ val translatedFriendGroupName = context.getString(R.string.bnv_friend)
+
+ CoroutineScope(Dispatchers.IO).launch {
+ repository.updateFriendGroupName(translatedFriendGroupName)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/ContactRemoteViewsFactory.kt b/app/src/main/java/com/ivyclub/contact/service/ContactRemoteViewsFactory.kt
new file mode 100644
index 00000000..f9629e85
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/ContactRemoteViewsFactory.kt
@@ -0,0 +1,100 @@
+package com.ivyclub.contact.service
+
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+import android.widget.RemoteViewsService
+import com.ivyclub.contact.R
+import com.ivyclub.contact.util.getDayOfMonth
+import com.ivyclub.contact.util.getDayOfWeek
+import com.ivyclub.contact.util.getExactMonth
+import com.ivyclub.contact.util.getExactYear
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.SimplePlanData
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.collect
+import java.sql.Date
+
+class ContactRemoteViewsFactory(
+ private val context: Context,
+ private val repository: ContactRepository
+) : RemoteViewsService.RemoteViewsFactory {
+
+ private var data = emptyList()
+
+ private val refreshingJob: Job =
+ CoroutineScope(Dispatchers.IO).launch(start = CoroutineStart.LAZY) {
+ getPlanListForWidget()
+ }
+
+ private suspend fun getPlanListForWidget() {
+ val current = Date(System.currentTimeMillis())
+
+ repository.loadPlanListWithFlow().collect { newPlanList ->
+ val tmpList = newPlanList.filter {
+ it.date.getExactYear() == current.getExactYear() &&
+ it.date.getExactMonth() == current.getExactMonth()
+ }
+
+ if (data != tmpList) {
+ data = tmpList
+ WidgetProvider.sendRefreshBroadcast(context)
+ }
+ }
+ }
+
+ override fun onCreate() {}
+
+ override fun onDataSetChanged() {
+ if (!refreshingJob.isActive) refreshingJob.start()
+ }
+
+ override fun onDestroy() {}
+
+ override fun getCount() = data.size
+
+ override fun getViewAt(position: Int): RemoteViews {
+ val listviewWidget = RemoteViews(context.packageName, R.layout.item_widget)
+ val planDate = data[position].date
+ val dateText = String.format(
+ context.getString(R.string.format_date_day),
+ planDate.getDayOfMonth(),
+ planDate.getDayOfWeek().translated.invoke()
+ )
+ with(listviewWidget) {
+ setTextViewText(
+ R.id.tv_widget_plan_date,
+ dateText
+ )
+
+ setTextViewText(R.id.tv_widget_plan_title, data[position].title)
+
+ val intent = Intent().apply {
+ putExtra(WIDGET_PLAN_ID, this@ContactRemoteViewsFactory.data[position].id)
+ }
+
+ setOnClickFillInIntent(R.id.ll_plan, intent)
+ }
+ return listviewWidget
+ }
+
+ override fun getLoadingView(): RemoteViews? {
+ return null
+ }
+
+ override fun getViewTypeCount(): Int {
+ return 1
+ }
+
+ override fun getItemId(position: Int): Long {
+ return 0
+ }
+
+ override fun hasStableIds(): Boolean {
+ return false
+ }
+
+ companion object {
+ const val WIDGET_PLAN_ID = "widget_plan_id"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/ContactRemoteViewsService.kt b/app/src/main/java/com/ivyclub/contact/service/ContactRemoteViewsService.kt
new file mode 100644
index 00000000..ef3ac802
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/ContactRemoteViewsService.kt
@@ -0,0 +1,17 @@
+package com.ivyclub.contact.service
+
+import android.content.Intent
+import android.widget.RemoteViewsService
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class ContactRemoteViewsService : RemoteViewsService() {
+ @Inject
+ lateinit var repository: ContactRepository
+
+ override fun onGetViewFactory(intent: Intent?): RemoteViewsFactory {
+ return ContactRemoteViewsFactory(this.applicationContext, repository)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/WidgetProvider.kt b/app/src/main/java/com/ivyclub/contact/service/WidgetProvider.kt
new file mode 100644
index 00000000..3e603ccf
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/WidgetProvider.kt
@@ -0,0 +1,85 @@
+package com.ivyclub.contact.service
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+import com.ivyclub.contact.R
+import com.ivyclub.contact.service.ContactRemoteViewsFactory.Companion.WIDGET_PLAN_ID
+import com.ivyclub.contact.ui.main.MainActivity
+import com.ivyclub.contact.util.getExactMonth
+import java.sql.Date
+
+
+class WidgetProvider : AppWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray?
+ ) {
+ appWidgetIds?.forEach {
+ val serviceIntent = Intent(context, ContactRemoteViewsService::class.java)
+ val widget = RemoteViews(context.packageName, R.layout.widget)
+ widget.setRemoteAdapter(R.id.lv_widget_plan_list, serviceIntent)
+
+ val currentMonth = Date(System.currentTimeMillis()).getExactMonth()
+ widget.setTextViewText(
+ R.id.tv_widget,
+ String.format(context.getString(R.string.format_widget_plan), currentMonth)
+ )
+
+ val itemClickIntent = Intent(context, WidgetProvider::class.java).apply {
+ action = ACTION_ITEM_CLICK
+ }
+ val itemClickPendingIntent =
+ PendingIntent.getBroadcast(context, 0, itemClickIntent, 0)
+
+ widget.setPendingIntentTemplate(R.id.lv_widget_plan_list, itemClickPendingIntent)
+
+ val refreshClickIntent = Intent(context, WidgetProvider::class.java).apply {
+ action = ACTION_APPWIDGET_UPDATE
+ }
+ val refreshClickPendingIntent =
+ PendingIntent.getBroadcast(context, 0, refreshClickIntent, 0)
+ widget.setOnClickPendingIntent(R.id.iv_btn_widget_refresh, refreshClickPendingIntent)
+
+ appWidgetManager.updateAppWidget(it, widget)
+ }
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ super.onReceive(context, intent)
+ when(intent.action) {
+ ACTION_APPWIDGET_UPDATE -> {
+ val mgr = AppWidgetManager.getInstance(context)
+ val cn = ComponentName(context, WidgetProvider::class.java)
+ mgr.notifyAppWidgetViewDataChanged(mgr.getAppWidgetIds(cn), R.id.lv_widget_plan_list)
+ }
+
+ ACTION_ITEM_CLICK -> {
+ val activityIntent = Intent(context, MainActivity::class.java).apply {
+ putExtra(WIDGET_PLAN_ID, intent.getLongExtra(WIDGET_PLAN_ID, -1L))
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+
+ context.startActivity(activityIntent)
+ }
+ }
+ }
+
+ companion object {
+ private const val ACTION_ITEM_CLICK = "item_click"
+
+ fun sendRefreshBroadcast(context: Context) {
+ val intent = Intent(ACTION_APPWIDGET_UPDATE)
+ intent.component = ComponentName(context, WidgetProvider::class.java)
+ context.sendBroadcast(intent)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/password_timer/PasswordTimerWorker.kt b/app/src/main/java/com/ivyclub/contact/service/password_timer/PasswordTimerWorker.kt
new file mode 100644
index 00000000..45df5ef2
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/password_timer/PasswordTimerWorker.kt
@@ -0,0 +1,32 @@
+package com.ivyclub.contact.service.password_timer
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.ivyclub.data.ContactPreference
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+
+@HiltWorker
+class PasswordTimerWorker @AssistedInject constructor(
+ @Assisted context: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val preferences: ContactPreference) : CoroutineWorker(context, workerParams) {
+ override suspend fun doWork(): Result = coroutineScope {
+ val timer = 300 - 1
+
+ repeat(300) {
+ preferences.setPasswordTimer(timer - it)
+ delay(1000)
+ }
+
+ preferences.setPasswordTimer(-1)
+ preferences.setPasswordTryCount(0)
+ preferences.stopObservePasswordTimer()
+
+ Result.success()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMaker.kt b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMaker.kt
new file mode 100644
index 00000000..f5c61676
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMaker.kt
@@ -0,0 +1,16 @@
+package com.ivyclub.contact.service.plan_reminder
+
+import com.ivyclub.data.model.SimplePlanData
+
+interface PlanReminderMaker {
+ suspend fun makePlanReminders(planData: SimplePlanData)
+
+ suspend fun cancelPlanReminder(planData: SimplePlanData)
+
+ companion object {
+ const val ACTION_ALARM = "com.ivyclub.contact.Alarm"
+ const val REMINDER_TITLE = "reminder_title"
+ const val REMINDER_TEXT = "reminder_text"
+ const val REMINDER_PLAN_ID = "reminder_plan_id"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMakerImpl.kt b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMakerImpl.kt
new file mode 100644
index 00000000..a2081635
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMakerImpl.kt
@@ -0,0 +1,237 @@
+package com.ivyclub.contact.service.plan_reminder
+
+import android.app.AlarmManager
+import android.app.AlarmManager.RTC_WAKEUP
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Context.ALARM_SERVICE
+import android.content.Intent
+import android.os.Build
+import com.ivyclub.contact.R
+import com.ivyclub.contact.receivers.AlarmReceiver
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.ACTION_ALARM
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.REMINDER_PLAN_ID
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.REMINDER_TEXT
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker.Companion.REMINDER_TITLE
+import com.ivyclub.contact.util.*
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.SimplePlanData
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.sql.Date
+import java.text.SimpleDateFormat
+import java.util.*
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PlanReminderMakerImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val repository: ContactRepository
+) : PlanReminderMaker {
+
+ private val alarmManager = context.getSystemService(ALARM_SERVICE) as AlarmManager
+
+ override suspend fun makePlanReminders(planData: SimplePlanData) {
+
+ val participants = planData.participant.map { participantId ->
+ repository.getSimpleFriendDataById(participantId).name
+ }
+
+ makeStartReminder(planData)
+ makeLastPlanReminder(planData, participants)
+ makeEndReminder(planData, participants)
+ }
+
+ override suspend fun cancelPlanReminder(planData: SimplePlanData) {
+ cancelStartReminder(planData.date)
+ cancelLastReminder(planData.id)
+ cancelEndReminder(planData.id)
+ }
+
+ private fun makeLastPlanReminder(
+ planData: SimplePlanData,
+ participants: List
+ ) {
+ if (System.currentTimeMillis() > planData.date.time) return
+
+ val reminderTitle = context.getString(R.string.plan_reminder_notification_title)
+
+ val reminderText: String
+ val strFormat: String
+ val hourMinuteFormat = SimpleDateFormat(context.getString(R.string.format_hour_minute), Locale.getDefault())
+ val strHourMinute = hourMinuteFormat.format(planData.date)
+ if (participants.isEmpty()) {
+ strFormat = context.getString(R.string.format_plan_reminder_notification_solo)
+ reminderText =
+ if (Locale.getDefault().language == "en") String.format(strFormat, planData.title, strHourMinute)
+ else String.format(strFormat, strHourMinute, planData.title)
+ }
+ else {
+ strFormat = context.getString(R.string.format_plan_reminder_notification_with_friends)
+ val firstParticipant = String.format(
+ context.getString(R.string.format_friend_title),
+ participants.first()
+ )
+ val textParticipants =
+ if (participants.size > 1) {
+ String.format(
+ context.getString(R.string.format_friend_count_etc),
+ firstParticipant,
+ participants.size - 1
+ )
+ } else firstParticipant
+ reminderText =
+ if (Locale.getDefault().language == "en") String.format(strFormat, textParticipants, strHourMinute)
+ else String.format(strFormat, strHourMinute, textParticipants)
+ }
+
+ val intent =
+ Intent(context, AlarmReceiver::class.java).apply {
+ putExtra(REMINDER_TITLE, reminderTitle)
+ putExtra(REMINDER_TEXT, reminderText)
+ putExtra(REMINDER_PLAN_ID, planData.id)
+ action = ACTION_ALARM
+ }
+
+ var planNotiTime = repository.getPlanNotificationTime()
+ if (planNotiTime == 0L) {
+ planNotiTime = HOUR_IN_MILLIS
+ repository.setPlanNotificationTime(planNotiTime)
+ }
+
+ getReminderPendingIntent(planData.id.toInt(), intent)?.let { pendingIntent ->
+ setAlarm(planData.date.time - planNotiTime, pendingIntent)
+ }
+ }
+
+ private fun makeStartReminder(planData: SimplePlanData) {
+
+ val startTime =
+ planData.date.getNewTime(repository.getStartAlarmHour(), 0).time
+
+ if (System.currentTimeMillis() > startTime) return
+
+ val reminderTitle = context.getString(R.string.plan_morning_notification_title)
+ val reminderText = context.getString(R.string.plan_morning_notification_content)
+
+ val intent = Intent(context, AlarmReceiver::class.java).apply {
+ putExtra(REMINDER_TITLE, reminderTitle)
+ putExtra(REMINDER_TEXT, reminderText)
+ action = ACTION_ALARM
+ }
+
+ getReminderPendingIntent(getReminderRequestCodeWithPlanDate(planData.date), intent)?.let { pendingIntent ->
+ setAlarm(startTime, pendingIntent)
+ }
+ }
+
+ private fun makeEndReminder(
+ planData: SimplePlanData,
+ participants: List
+ ) {
+ val endTime =
+ planData.date.getNewTime(repository.getEndAlarmHour(), 0).time - (10 * MINUTE_IN_MILLIS)
+
+ if (System.currentTimeMillis() > endTime) return
+
+ val reminderTitle = context.getString(R.string.plan_night_notification_title)
+
+ val reminderText: String
+ val strFormat: String
+ if (participants.isEmpty()) {
+ strFormat = context.getString(R.string.format_after_plan)
+ reminderText = String.format(strFormat, planData.title)
+ }
+ else {
+ strFormat = context.getString(R.string.format_after_plan_with_friends)
+ val firstParticipant = String.format(
+ context.getString(R.string.format_friend_title),
+ participants.first()
+ )
+ val textParticipants =
+ if (participants.size > 1) {
+ String.format(
+ context.getString(R.string.format_friend_count_etc),
+ firstParticipant,
+ participants.size - 1
+ )
+ } else firstParticipant
+ reminderText = String.format(strFormat, textParticipants)
+ }
+
+ val intent =
+ Intent(context, AlarmReceiver::class.java).apply {
+ putExtra(REMINDER_TITLE, reminderTitle)
+ putExtra(REMINDER_TEXT, reminderText)
+ putExtra(REMINDER_PLAN_ID, planData.id)
+ action = ACTION_ALARM
+ }
+
+ getReminderPendingIntent((-(planData.id)).toInt(), intent)?.let { pendingIntent ->
+ setAlarm(endTime, pendingIntent)
+ }
+ }
+
+ private fun cancelLastReminder(planId: Long) {
+ cancelAlarm(planId.toInt())
+ }
+
+ private suspend fun cancelStartReminder(planDate: Date) {
+ val planDayStartTime = planDate.getNewTime(repository.getStartAlarmHour(), 0).time
+ val planDayEndTime = planDate.getNewTime(repository.getEndAlarmHour(), 0).time
+ val plansOnSameDay = repository.getPlanListAfter(planDayStartTime).filter { it.date.time < planDayEndTime }
+ if (plansOnSameDay.isEmpty()) {
+ cancelAlarm(getReminderRequestCodeWithPlanDate(planDate))
+ }
+ }
+
+ private fun cancelEndReminder(planId: Long) {
+ cancelAlarm(-planId.toInt())
+ }
+
+ private fun getReminderRequestCodeWithPlanDate(planDate: Date): Int {
+ val tmpStrRequestCode =
+ "${planDate.getExactYear()}${planDate.getExactMonth()}${planDate.getDayOfMonth()}"
+ return tmpStrRequestCode.toInt()
+ }
+
+ private fun getReminderPendingIntent(
+ requestCode: Int,
+ intent: Intent,
+ flag: Int =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+ ): PendingIntent? {
+ return PendingIntent
+ .getBroadcast(
+ context,
+ requestCode,
+ intent,
+ flag
+ )
+ }
+
+ private fun setAlarm(time: Long, pendingIntent: PendingIntent) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ alarmManager.setExactAndAllowWhileIdle(
+ RTC_WAKEUP, time, pendingIntent
+ )
+ } else {
+ alarmManager.setExact(
+ RTC_WAKEUP, time, pendingIntent
+ )
+ }
+ }
+
+ private fun cancelAlarm(requestCode: Int) {
+ val intent = Intent(context, AlarmReceiver::class.java).apply {
+ action = ACTION_ALARM
+ }
+ getReminderPendingIntent(requestCode, intent)?.let { pendingIntent ->
+ alarmManager.cancel(pendingIntent)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMakerModule.kt b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMakerModule.kt
new file mode 100644
index 00000000..b085c654
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderMakerModule.kt
@@ -0,0 +1,13 @@
+package com.ivyclub.contact.service.plan_reminder
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@InstallIn(SingletonComponent::class)
+@Module
+interface PlanReminderMakerModule {
+ @Binds
+ fun providePlanReminderMakerImpl(planReminderMaker: PlanReminderMakerImpl): PlanReminderMaker
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderNotification.kt b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderNotification.kt
new file mode 100644
index 00000000..b1c4ddad
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/service/plan_reminder/PlanReminderNotification.kt
@@ -0,0 +1,84 @@
+package com.ivyclub.contact.service.plan_reminder
+
+import android.app.Notification.DEFAULT_SOUND
+import android.app.Notification.DEFAULT_VIBRATE
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import com.ivyclub.contact.R
+import com.ivyclub.contact.ui.main.MainActivity
+
+object PlanReminderNotification {
+ private const val CHANNEL_ID = "plan_reminder"
+ private const val CHANNEL_NAME = "contact plans"
+ const val NOTIFICATION = "notification"
+ const val NOTI_PLAN_ID = "noti_plan_id"
+
+ private var notificationManager: NotificationManager? = null
+
+ fun makePlanNotification(context: Context, title: String, text: String, planId: Long = -1L) {
+
+ checkNotificationManager(context)
+ createNotificationChannel()
+
+ val intent = Intent(context, MainActivity::class.java).apply {
+ putExtra(NOTIFICATION, true)
+ putExtra(NOTI_PLAN_ID, planId)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ context,
+ planId.toInt(),
+ intent,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ } else {
+ PendingIntent.FLAG_UPDATE_CURRENT
+ }
+ )
+
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setColor(ContextCompat.getColor(context, R.color.green_200))
+ .setContentTitle(title)
+ .setContentText(text)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setContentIntent(pendingIntent)
+ .setDefaults(DEFAULT_SOUND or DEFAULT_VIBRATE)
+ .setAutoCancel(true)
+ .build()
+
+ notificationManager?.notify(planId.toInt(), notification)
+ }
+
+ private fun checkNotificationManager(context: Context) {
+ if (notificationManager == null) {
+ notificationManager = context.getSystemService(
+ Context.NOTIFICATION_SERVICE
+ ) as NotificationManager
+ }
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+
+ if (notificationManager?.getNotificationChannel(CHANNEL_ID) != null) return
+
+ val notiChannel = NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ enableVibration(true)
+ }
+
+ notificationManager?.createNotificationChannel(notiChannel)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/MainActivity.kt b/app/src/main/java/com/ivyclub/contact/ui/main/MainActivity.kt
new file mode 100644
index 00000000..a79c9980
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/MainActivity.kt
@@ -0,0 +1,170 @@
+package com.ivyclub.contact.ui.main
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.MotionEvent
+import android.widget.EditText
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.core.view.isVisible
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.setupWithNavController
+import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ActivityMainBinding
+import com.ivyclub.contact.service.ContactRemoteViewsFactory.Companion.WIDGET_PLAN_ID
+import com.ivyclub.contact.service.plan_reminder.PlanReminderNotification.NOTIFICATION
+import com.ivyclub.contact.service.plan_reminder.PlanReminderNotification.NOTI_PLAN_ID
+import com.ivyclub.contact.ui.main.friend.FriendFragmentDirections
+import com.ivyclub.contact.ui.onboard.OnBoardingActivity
+import com.ivyclub.contact.ui.password.PasswordActivity
+import com.ivyclub.contact.util.BaseActivity
+import com.ivyclub.contact.util.hideKeyboard
+import dagger.hilt.android.AndroidEntryPoint
+import android.app.ActivityManager
+import android.content.Context
+import android.os.Build
+
+@AndroidEntryPoint
+class MainActivity : BaseActivity(R.layout.activity_main) {
+ private val viewModel: MainViewModel by viewModels()
+ private lateinit var getOnPasswordResult: ActivityResultLauncher
+
+ private lateinit var navController: NavController
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setObserver()
+ setNavigation()
+ viewModel.checkOnBoarding()
+ viewModel.checkPasswordOnCreate()
+ setOnPasswordResult()
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.checkPasswordOnResume()
+ checkFromNotification()
+ checkFromWidget()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+ val curComponentInfoInString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ activityManager.appTasks[0].taskInfo.topActivity.toString()
+ } else {
+ activityManager.getRunningTasks(1)[0].topActivity.toString()
+ }
+
+ if ("OssLicensesMenuActivity" !in curComponentInfoInString) {
+ viewModel.lock()
+ }
+ }
+
+ private fun checkFromNotification() {
+ if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return
+
+ val fromNotification = intent.getBooleanExtra(NOTIFICATION, false)
+ val planId = intent.getLongExtra(NOTI_PLAN_ID, -1L)
+ if (fromNotification) {
+ when (planId) {
+ -1L -> binding.bnvMain.selectedItemId = R.id.navigation_plan
+ else -> {
+ if (navController.currentDestination?.id == R.id.navigation_friend) {
+ navController.navigate(
+ FriendFragmentDirections.actionNavigationFriendToPlanDetailsFragment(
+ planId
+ )
+ )
+ }
+ }
+ }
+ intent.resetIntent(NOTIFICATION, NOTI_PLAN_ID)
+ }
+ }
+
+ private fun checkFromWidget() {
+ if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return
+
+ val widgetPlanId = intent.getLongExtra(WIDGET_PLAN_ID, -1L)
+ if (widgetPlanId != -1L && navController.currentDestination?.id == R.id.navigation_friend) {
+ navController.navigate(
+ FriendFragmentDirections.actionNavigationFriendToPlanDetailsFragment(
+ widgetPlanId
+ )
+ )
+ intent.resetIntent(WIDGET_PLAN_ID)
+ }
+ }
+
+ private fun Intent.resetIntent(vararg keys: String) {
+ keys.forEach { key -> removeExtra(key) }
+ }
+
+ private fun setObserver() {
+ viewModel.onBoard.observe(this, {
+ if (it) {
+ val intent = Intent(this, OnBoardingActivity::class.java)
+ startActivity(intent)
+ }
+ })
+ viewModel.moveToConfirmPassword.observe(this) {
+ val intent = Intent(this, PasswordActivity::class.java)
+ getOnPasswordResult.launch(intent)
+ }
+ }
+
+ private fun setOnPasswordResult() {
+ getOnPasswordResult =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ viewModel.unlock()
+ }
+ }
+ }
+
+ private fun setNavigation() {
+ val navView: BottomNavigationView = binding.bnvMain
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.fcv_main) as NavHostFragment
+ navController = navHostFragment.navController
+ navView.setupWithNavController(navController)
+ navController.addOnDestinationChangedListener { _, destination, _ ->
+ when (destination.id) {
+ R.id.navigation_friend, R.id.navigation_plan -> {
+ binding.bnvMain.isVisible = true
+ }
+ else -> binding.bnvMain.isVisible = false
+ }
+ }
+ }
+
+ override fun dispatchTouchEvent(motionEvent: MotionEvent?): Boolean {
+ val view = currentFocus
+ motionEvent?.let {
+ if (
+ view != null &&
+ (it.action == MotionEvent.ACTION_UP || it.action == MotionEvent.ACTION_MOVE) &&
+ view is EditText
+ ) {
+ val intArr = IntArray(2)
+ view.getLocationOnScreen(intArr)
+ val x = it.rawX + view.left - intArr[0]
+ val y = it.rawY + view.top - intArr[1]
+ if (x < view.left || x > view.right || y < view.top || y > view.bottom) {
+ binding.hideKeyboard()
+ view.clearFocus()
+ }
+ }
+ }
+ return super.dispatchTouchEvent(motionEvent)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/MainViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/MainViewModel.kt
new file mode 100644
index 00000000..734de238
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/MainViewModel.kt
@@ -0,0 +1,58 @@
+package com.ivyclub.contact.ui.main
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ private lateinit var password: String
+ private var lock = false
+ private val _showOnBoarding = MutableLiveData()
+ val onBoard: LiveData get() = _showOnBoarding
+ private val _moveToConfirmPassword = SingleLiveEvent()
+ val moveToConfirmPassword: LiveData get() = _moveToConfirmPassword
+
+ fun checkOnBoarding() {
+ viewModelScope.launch {
+ _showOnBoarding.value = repository.getShowOnBoardingState() != false
+ }
+ }
+
+ fun checkPasswordOnCreate() {
+ viewModelScope.launch {
+ password = repository.getPassword()
+ if (password.isNotEmpty()) {
+ _moveToConfirmPassword.call()
+ }
+ }
+ }
+
+ fun unlock() {
+ lock = false
+ }
+
+ fun lock() {
+ viewModelScope.launch {
+ password = repository.getPassword()
+ if (password.isNotEmpty()) {
+ lock = true
+ }
+ }
+ }
+
+ fun checkPasswordOnResume() {
+ if (lock) {
+ _moveToConfirmPassword.call()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/AddEditFriendFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/AddEditFriendFragment.kt
new file mode 100644
index 00000000..2c323885
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/AddEditFriendFragment.kt
@@ -0,0 +1,204 @@
+package com.ivyclub.contact.ui.main.add_edit_friend
+
+import android.app.Activity.RESULT_CANCELED
+import android.app.Activity.RESULT_OK
+import android.app.DatePickerDialog
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.os.Build
+import android.os.Bundle
+import android.provider.MediaStore
+import android.telephony.PhoneNumberFormattingTextWatcher
+import android.util.Log
+import android.view.View
+import android.widget.ArrayAdapter
+import android.widget.Toast
+import androidx.activity.addCallback
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentAddEditFriendBinding
+import com.ivyclub.contact.ui.main.MainViewModel
+import com.ivyclub.contact.util.*
+import dagger.hilt.android.AndroidEntryPoint
+import java.sql.Date
+
+
+@AndroidEntryPoint
+class AddEditFriendFragment :
+ BaseFragment(R.layout.fragment_add_edit_friend) {
+
+ private val viewModel: AddEditFriendViewModel by viewModels()
+ private val activityViewModel: MainViewModel by activityViewModels()
+ val extraInfoListAdapter by lazy { ExtraInfoListAdapter(viewModel::removeExtraInfo) }
+ private val args: AddEditFriendFragmentArgs by navArgs()
+ lateinit var spinnerAdapter: ArrayAdapter
+ private var currentBitmap: Bitmap? = null
+ private var newId: Long = -1L
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ binding.fragment = this
+ binding.ivProfileImage.clipToOutline = true
+ setObserver()
+ setClickListener()
+ setBackPressedListener()
+ setPhoneNumberTextChangedListener()
+ }
+
+ private fun setFriendData() {
+ if (args.friendId != -1L) {
+ viewModel.getFriendData(args.friendId)
+ viewModel.friendData.observe(viewLifecycleOwner) { friendData ->
+ binding.apply {
+ etName.setText(friendData.name)
+ etPhoneNumber.setText(friendData.phoneNumber)
+ tvBirthdayValue.text = friendData.birthday
+ spnGroup.setSelection(
+ this@AddEditFriendFragment.viewModel.groupIdList.indexOf(
+ friendData.groupId
+ )
+ )
+ }
+ viewModel.addExtraInfoList(friendData.extraInfo)
+ }
+ viewModel.loadProfileImage(args.friendId)
+ ?.let { binding.ivProfileImage.setImageBitmap(it) }
+ } else {
+ viewModel.createNewId()
+ }
+ }
+
+ private fun setObserver() {
+ viewModel.groupNameList.observe(viewLifecycleOwner) {
+ initSpinnerAdapter(it)
+ setFriendData()
+ }
+ viewModel.isSaveButtonClicked.observe(viewLifecycleOwner) {
+ with(binding) {
+ this@AddEditFriendFragment.viewModel.saveFriendData(
+ etPhoneNumber.text.toString(),
+ etName.text.toString(),
+ tvBirthdayValue.text.toString(),
+ this@AddEditFriendFragment.viewModel.groupIdList[spnGroup.selectedItemPosition],
+ extraInfoListAdapter.currentList,
+ args.friendId
+ )
+ if (args.friendId != -1L) {
+ this@AddEditFriendFragment.viewModel.saveProfileImage(
+ currentBitmap,
+ args.friendId
+ )
+ } else {
+ this@AddEditFriendFragment.viewModel.saveProfileImage(currentBitmap, newId)
+ }
+ findNavController().popBackStack()
+ Snackbar.make(
+ binding.root,
+ getString(R.string.add_edit_success_message),
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+ }
+ viewModel.extraInfos.observe(viewLifecycleOwner) {
+ extraInfoListAdapter.submitList(it.toMutableList())
+ }
+ viewModel.newId.observe(viewLifecycleOwner) {
+ newId = it
+ }
+ viewModel.nameValidation.observe(viewLifecycleOwner) {
+ binding.tvNameValidCheck.text = getString(it)
+ }
+ }
+
+ private fun setClickListener() {
+ with(binding) {
+ ivBackIcon.setOnClickListener {
+ showBackPressedDialog()
+ }
+ tvBirthdayValue.setOnClickListener {
+ val today = Date(System.currentTimeMillis())
+ val listener = DatePickerDialog.OnDateSetListener { _, year, month, day ->
+ tvBirthdayValue.text = String.format(
+ getString(R.string.add_edit_friend_fragment_birthday_value),
+ year,
+ month + 1,
+ day
+ )
+ this@AddEditFriendFragment.viewModel.showClearButtonVisible(true)
+ }
+ DatePickerDialog(
+ requireContext(),
+ listener,
+ today.getExactYear(),
+ today.getExactMonth() - 1,
+ today.getDayOfMonth()
+ ).show()
+ }
+ ivClearBirthday.setOnClickListener {
+ tvBirthdayValue.text = ""
+ this@AddEditFriendFragment.viewModel.showClearButtonVisible(false)
+ }
+ ivProfileImage.setOnClickListener {
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "image/*"
+ filterActivityLauncher.launch(intent)
+ }
+ }
+ }
+
+ private fun setPhoneNumberTextChangedListener() {
+ binding.etPhoneNumber.addTextChangedListener(PhoneNumberFormattingTextWatcher())
+ }
+
+ private val filterActivityLauncher: ActivityResultLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult ->
+ activityViewModel.unlock()
+ if (activityResult.resultCode == RESULT_OK && activityResult.data != null) {
+ val currentImageUri = activityResult.data?.data
+ try {
+ currentImageUri?.let {
+ activity?.let {
+ currentBitmap = requireActivity().uriToBitmap(currentImageUri)
+ binding.ivProfileImage.setImageBitmap(currentBitmap)
+ }
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ } else if (activityResult.resultCode == RESULT_CANCELED) {
+ Toast.makeText(context, "사진 선택 취소", Toast.LENGTH_LONG).show()
+ } else {
+ Log.d("ActivityResult", "something wrong")
+ }
+ }
+
+ private fun showBackPressedDialog() {
+ if (context == null) return
+ requireContext().showAlertDialog(getString(R.string.ask_back_while_edit), {
+ findNavController().popBackStack()
+ })
+ }
+
+ private fun setBackPressedListener() {
+ if (activity == null) return
+ requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
+ showBackPressedDialog()
+ }
+ }
+
+ private fun initSpinnerAdapter(groups: List) {
+ if (context == null) return
+ spinnerAdapter =
+ ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, groups)
+ spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ binding.spnGroup.adapter = spinnerAdapter
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/AddEditFriendViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/AddEditFriendViewModel.kt
new file mode 100644
index 00000000..128c8dfa
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/AddEditFriendViewModel.kt
@@ -0,0 +1,160 @@
+package com.ivyclub.contact.ui.main.add_edit_friend
+
+import android.graphics.Bitmap
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.R
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.image.ImageManager
+import com.ivyclub.data.model.FriendData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AddEditFriendViewModel @Inject constructor(val repository: ContactRepository) : ViewModel() {
+
+ private val _groupNameList = MutableLiveData>()
+ val groupNameList: LiveData> get() = _groupNameList
+ private val _extraInfos = MutableLiveData>()
+ val extraInfos: LiveData> get() = _extraInfos
+ private val _isSaveButtonClicked = SingleLiveEvent()
+ val isSaveButtonClicked: LiveData get() = _isSaveButtonClicked
+ private val _friendData = MutableLiveData()
+ val friendData: LiveData get() = _friendData
+ private val _showClearButtonVisible = MutableLiveData()
+ val showClearButtonVisible: LiveData get() = _showClearButtonVisible
+ private val extraInfoList = mutableListOf()
+ private val _newId = MutableLiveData()
+ val newId: LiveData get() = _newId
+ private val _nameValidation = MutableLiveData()
+ val nameValidation: LiveData get() = _nameValidation
+ val isNameValid = MutableLiveData()
+
+
+ lateinit var groupIdList: List
+
+ init {
+ viewModelScope.launch {
+ val groups = repository.loadGroups()
+ _groupNameList.value = groups.map { it.name }
+ groupIdList = groups.map { it.id }
+ }
+ }
+
+ fun getFriendData(friendId: Long) {
+ if (friendId == -1L) return
+ viewModelScope.launch {
+ val friendData = repository.getFriendDataById(friendId)
+ _friendData.value = friendData
+ showClearButtonVisible(friendData.birthday.isNotEmpty())
+ }
+ }
+
+ fun addExtraInfo() {
+ extraInfoList.add(FriendExtraInfoData(EMPTY_STRING, EMPTY_STRING))
+ _extraInfos.value = extraInfoList
+ }
+
+ fun addExtraInfoList(extraInfoMap: Map) {
+ extraInfoMap.keys.forEach { key ->
+ val value = extraInfoMap[key]
+ if (value != null) {
+ extraInfoList.add(FriendExtraInfoData(key, value))
+ }
+ }
+ _extraInfos.value = extraInfoList
+ }
+
+ fun removeExtraInfo(position: Int) {
+ extraInfoList.removeAt(position)
+ _extraInfos.value = extraInfoList
+ }
+
+ fun onSaveButtonClicked() {
+ _isSaveButtonClicked.call()
+ }
+
+ fun checkNameValid(inputName: String) {
+ when {
+ inputName.isEmpty() -> {
+ _nameValidation.value = R.string.add_edit_friend_required_check
+ isNameValid.value = false
+ }
+ 15 < inputName.length -> {
+ _nameValidation.value = R.string.add_edit_friend_over_length
+ isNameValid.value = false
+ }
+ else -> {
+ _nameValidation.value = R.string.empty_string
+ isNameValid.value = true
+ }
+ }
+ }
+
+ fun saveFriendData(
+ phoneNumber: String,
+ name: String,
+ birthday: String,
+ groupId: Long,
+ extraInfo: List,
+ id: Long
+ ) {
+ val extraInfoMap = mutableMapOf()
+ extraInfo.forEach {
+ if (it.title.isNotEmpty() || it.value.isNotEmpty()) {
+ extraInfoMap[it.title] = it.value
+ }
+ }
+ viewModelScope.launch {
+ if (id == -1L) {
+ repository.saveFriend(
+ FriendData(
+ phoneNumber,
+ name,
+ birthday,
+ groupId,
+ listOf(),
+ false,
+ extraInfoMap
+ )
+ )
+ } else {
+ repository.updateFriend(
+ phoneNumber,
+ name,
+ birthday,
+ groupId,
+ extraInfoMap,
+ id
+ )
+ }
+
+ }
+ }
+
+ fun showClearButtonVisible(show: Boolean) {
+ _showClearButtonVisible.value = show
+ }
+
+ fun saveProfileImage(currentBitmap: Bitmap?, friendId: Long) {
+ ImageManager.saveProfileImage(currentBitmap, friendId)
+ }
+
+ fun loadProfileImage(friendId: Long): Bitmap? {
+ return ImageManager.loadProfileImage(friendId)
+ }
+
+ fun createNewId() {
+ viewModelScope.launch {
+ _newId.postValue(repository.getLastFriendId() + 1)
+ }
+ }
+
+ companion object {
+ const val EMPTY_STRING = ""
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/ExtraInfoListAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/ExtraInfoListAdapter.kt
new file mode 100644
index 00000000..2313064d
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/ExtraInfoListAdapter.kt
@@ -0,0 +1,64 @@
+package com.ivyclub.contact.ui.main.add_edit_friend
+
+import android.view.ViewGroup
+import androidx.core.widget.doOnTextChanged
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemAddFriendExtraInfoBinding
+import com.ivyclub.contact.util.binding
+
+class ExtraInfoListAdapter(private val onRemoveButtonClick: (Int) -> (Unit)) :
+ ListAdapter(DIFF_UTIL) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExtraInfoViewHolder {
+ return ExtraInfoViewHolder(parent.binding(R.layout.item_add_friend_extra_info))
+ }
+
+ override fun onBindViewHolder(holder: ExtraInfoViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ inner class ExtraInfoViewHolder(private val binding: ItemAddFriendExtraInfoBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ init {
+ binding.etExtraInfoTitle.doOnTextChanged { text, _, _, _ ->
+ val currentExtraInfo = getItem(adapterPosition)
+ currentExtraInfo.title = text.toString()
+ }
+ binding.etExtraInfoValue.doOnTextChanged { text, _, _, _ ->
+ val currentExtraInfo = getItem(adapterPosition)
+ currentExtraInfo.value = text.toString()
+ }
+ binding.ivRemoveExtraInfo.setOnClickListener {
+ onRemoveButtonClick(adapterPosition)
+ }
+ }
+
+ fun bind(extraInfo: FriendExtraInfoData) {
+ with(binding) {
+ etExtraInfoTitle.setText(extraInfo.title)
+ etExtraInfoValue.setText(extraInfo.value)
+ }
+ }
+
+ }
+
+ companion object {
+ private val DIFF_UTIL = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: FriendExtraInfoData,
+ newItem: FriendExtraInfoData
+ ): Boolean =
+ oldItem === newItem
+
+ override fun areContentsTheSame(
+ oldItem: FriendExtraInfoData,
+ newItem: FriendExtraInfoData
+ ): Boolean =
+ oldItem == newItem
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/FriendExtraInfoData.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/FriendExtraInfoData.kt
new file mode 100644
index 00000000..6b77d910
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_friend/FriendExtraInfoData.kt
@@ -0,0 +1,3 @@
+package com.ivyclub.contact.ui.main.add_edit_friend
+
+class FriendExtraInfoData(var title: String, var value: String)
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/AddEditPlanFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/AddEditPlanFragment.kt
new file mode 100644
index 00000000..7035a113
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/AddEditPlanFragment.kt
@@ -0,0 +1,236 @@
+package com.ivyclub.contact.ui.main.add_edit_plan
+
+import android.app.Activity
+import android.app.DatePickerDialog
+import android.app.TimePickerDialog
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentAddEditPlanBinding
+import com.ivyclub.contact.ui.main.MainViewModel
+import com.ivyclub.contact.ui.main.friend.dialog.SelectGroupFragment
+import com.ivyclub.contact.util.*
+import com.ivyclub.data.model.SimpleFriendData
+import dagger.hilt.android.AndroidEntryPoint
+import java.sql.Date
+import java.text.SimpleDateFormat
+import java.util.*
+
+const val MAX_PHOTO_COUNT = 5
+
+@AndroidEntryPoint
+class AddEditPlanFragment :
+ BaseFragment(R.layout.fragment_add_edit_plan) {
+
+ private val viewModel: AddEditPlanViewModel by viewModels()
+ private val args: AddEditPlanFragmentArgs by navArgs()
+ private val onBackPressedCallback by lazy {
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ showBackPressedDialog()
+ }
+ }
+ }
+ private val activityViewModel: MainViewModel by activityViewModels()
+ private val filterActivityLauncher: ActivityResultLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult ->
+ activityViewModel.unlock()
+ if (activityResult.resultCode == Activity.RESULT_OK && activityResult.data != null) {
+ if (activityResult.data?.clipData != null) { // 사용자가 이미지 여러 개 선택했을 때
+ val originImageCount = viewModel.bitmapUriList.value?.size ?: 0
+ val selectedImageCount = activityResult.data?.clipData?.itemCount ?: 0
+ val imageUriList = mutableListOf()
+ for (idx in 1..selectedImageCount) {
+ if (originImageCount + idx > MAX_PHOTO_COUNT) { // 사진은 다섯장까지만 추가 가능하도록 구현
+ binding.makeShortSnackBar(getString(R.string.add_edit_plan_fragment_over_five_pics))
+ break
+ }
+ imageUriList.add(
+ activityResult.data?.clipData?.getItemAt(idx - 1)?.uri ?: continue
+ )
+ }
+ viewModel.setPlanImageUri(imageUriList)
+ } else { // 사용자가 이미지 하나 선택했을 때
+ val imageUri = activityResult.data?.data
+ viewModel.setPlanImageUri(listOf(imageUri ?: return@registerForActivityResult))
+ binding.tvPhotoCount.text = String.format(
+ requireContext().getString(R.string.add_edit_plan_fragment_image_count),
+ 1,
+ MAX_PHOTO_COUNT
+ )
+ }
+ } else if (activityResult.resultCode == Activity.RESULT_CANCELED) {
+ Toast.makeText(context, "사진 선택 취소", Toast.LENGTH_LONG).show()
+ } else {
+ Log.e(this::class.simpleName, "ActivityResult Went Wrong")
+ }
+ }
+ private lateinit var photoAdapter: PhotoAdapter
+ private val bitmapList = mutableListOf()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.viewModel = viewModel
+ binding.dateFormat =
+ SimpleDateFormat(getString(R.string.format_simple_date), Locale.getDefault())
+
+ initPhotoAdapter()
+ initAddPhotoBtn()
+ initBackPressedCallback()
+ checkFrom()
+ setButtonClickListeners()
+ getGroupSelectFragmentResult()
+ setObservers()
+ observePlanPhotoList()
+ }
+
+ override fun onDetach() {
+ onBackPressedCallback.remove()
+ super.onDetach()
+ }
+
+ private fun initAddPhotoBtn() {
+ binding.btnAddImage.setOnClickListener {
+ if (binding.tvPhotoCount.text == "($MAX_PHOTO_COUNT/$MAX_PHOTO_COUNT)") {
+ binding.makeShortSnackBar(getString(R.string.add_edit_plan_fragment_over_five_pics))
+ return@setOnClickListener
+ }
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ intent.type = "image/*"
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+ filterActivityLauncher.launch(intent)
+ }
+ }
+
+ private fun observePlanPhotoList() {
+ viewModel.bitmapUriList.observe(viewLifecycleOwner) { newUriList ->
+ bitmapList.clear()
+ bitmapList.addAll(newUriList.map { uri ->
+ requireActivity().uriToBitmap(uri)
+ })
+ photoAdapter.submitList(newUriList)
+ }
+ }
+
+ private fun initPhotoAdapter() {
+ photoAdapter = PhotoAdapter(viewModel::deletePhotoAt)
+ binding.rvPhoto.adapter = photoAdapter
+ }
+
+ private fun initBackPressedCallback() {
+ activity?.onBackPressedDispatcher?.addCallback(this, onBackPressedCallback)
+ }
+
+ private fun setButtonClickListeners() {
+ with(binding) {
+ ivBtnBack.setOnClickListener { showBackPressedDialog() }
+ ivBtnEditPlanFinish.setOnClickListener {
+ showSavePlanDialog()
+ }
+ tvPlanTime.setOnClickListener {
+ this@AddEditPlanFragment.viewModel.planTime.value?.let {
+ showDatePickerDialog(it)
+ }
+ }
+ tvBtnLoadGroup.setOnClickListener {
+ SelectGroupFragment().show(
+ childFragmentManager,
+ SelectGroupFragment.TAG
+ )
+ }
+ }
+ }
+
+ private fun showDatePickerDialog(date: Date) {
+ if (context == null) return
+
+ DatePickerDialog(
+ requireContext(),
+ { _, y, m, d ->
+ showTimePickerDialog(date.getNewDate(y, m, d))
+ }, date.getExactYear(), date.getExactMonth() - 1, date.getDayOfMonth()
+ ).show()
+ }
+
+ private fun showTimePickerDialog(date: Date) {
+ if (context == null) return
+
+ TimePickerDialog(
+ requireContext(),
+ { _, h, m -> viewModel.setNewDate(date.getNewTime(h, m)) },
+ date.getHour(), date.getMinute(), false
+ ).show()
+ }
+
+ private fun showSavePlanDialog() {
+ context?.showAlertDialog(getString(R.string.ask_save_plan), {
+ viewModel.savePlan(bitmapList)
+ })
+ }
+
+ private fun showBackPressedDialog() {
+ context?.showAlertDialog(getString(R.string.ask_back_while_edit), {
+ viewModel.finish()
+ })
+ }
+
+ private fun checkFrom() {
+ if (args.planId != -1L) viewModel.getLastPlan(args.planId)
+ if (args.friendId != -1L) viewModel.addFriend(args.friendId)
+ }
+
+ private fun getGroupSelectFragmentResult() {
+ childFragmentManager.setFragmentResultListener("requestKey", this) { _, bundle ->
+ val result = bundle.getLong("bundleKey", -1L)
+ viewModel.addParticipantsByGroup(result)
+ }
+ }
+
+ private fun setObservers() {
+ viewModel.friendList.observe(viewLifecycleOwner) {
+ if (it.isNotEmpty() && binding.actPlanParticipants.adapter == null) {
+ setAutoCompleteAdapter(it)
+ }
+ }
+ viewModel.planParticipants.observe(viewLifecycleOwner) {
+ binding.flPlanParticipants.addChips(it.map { pair -> pair.name }) { index ->
+ viewModel.removeParticipant(index)
+ }
+ }
+ viewModel.snackbarMessage.observe(viewLifecycleOwner) {
+ if (context == null) return@observe
+ Snackbar.make(binding.root, getString(it), Snackbar.LENGTH_SHORT).show()
+ }
+ viewModel.finishEvent.observe(viewLifecycleOwner) {
+ findNavController().popBackStack()
+ }
+ }
+
+ private fun setAutoCompleteAdapter(friendList: List) {
+ if (context == null) return
+ val autoCompleteAdapter = FriendAutoCompleteAdapter(requireContext(), friendList)
+ with(binding.actPlanParticipants) {
+ setOnItemClickListener { _, _, i, _ ->
+ (adapter as FriendAutoCompleteAdapter).getItem(i)?.let {
+ viewModel.addParticipant(it)
+ text = null
+ }
+ }
+ setAdapter(autoCompleteAdapter)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/AddEditPlanViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/AddEditPlanViewModel.kt
new file mode 100644
index 00000000..fdc8fe11
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/AddEditPlanViewModel.kt
@@ -0,0 +1,214 @@
+package com.ivyclub.contact.ui.main.add_edit_plan
+
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.R
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.image.ImageManager
+import com.ivyclub.data.image.ImageType
+import com.ivyclub.data.model.PlanData
+import com.ivyclub.data.model.SimpleFriendData
+import com.ivyclub.data.model.SimplePlanData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import java.io.File
+import java.sql.Date
+import javax.inject.Inject
+import kotlin.collections.set
+
+@HiltViewModel
+class AddEditPlanViewModel @Inject constructor(
+ private val repository: ContactRepository,
+ private val reminderMaker: PlanReminderMaker
+) : ViewModel() {
+ private var planId = -1L
+ private val lastParticipants = mutableListOf()
+ private val friendMap = mutableMapOf()
+ private val _friendList = MutableLiveData>()
+ val friendList: LiveData> = _friendList
+ private val loadFriendsJob: Job = viewModelScope.launch {
+ val myFriends = repository.getSimpleFriendData()
+ myFriends.forEach {
+ friendMap[it.id] = it
+ }
+ _friendList.value = myFriends
+ }
+ val planTitle = MutableLiveData()
+ private val _planTime = MutableLiveData(Date(System.currentTimeMillis()))
+ val planTime: LiveData = _planTime
+ private val _planParticipants = MutableLiveData>(emptyList())
+ val planParticipants: LiveData> = _planParticipants
+ val planPlace = MutableLiveData()
+ val planContent = MutableLiveData()
+ private val _snackbarMessage = SingleLiveEvent()
+ val snackbarMessage: LiveData = _snackbarMessage
+ private val _finishEvent = SingleLiveEvent()
+ val finishEvent: LiveData = _finishEvent
+ private val _bitmapUriList = MutableLiveData>() // 계획 사진 uri 리스트
+ val bitmapUriList: LiveData> get() = _bitmapUriList
+ val maxPhotoCount = MAX_PHOTO_COUNT
+
+ fun getLastPlan(planId: Long) {
+ if (this.planId != -1L) return
+ this.planId = planId
+ viewModelScope.launch {
+ repository.getPlanDataById(planId).let {
+ lastParticipants.addAll(it.participant)
+
+ planTitle.value = it.title
+ _planTime.value = it.date
+ planPlace.value = it.place
+ planContent.value = it.content
+
+ loadFriendsJob.join()
+ val friendsOnPlan = mutableListOf()
+ it.participant.forEach { phoneNumber ->
+ friendMap[phoneNumber]?.let { friendInfo -> friendsOnPlan.add(friendInfo) }
+ }
+ _planParticipants.value = friendsOnPlan
+
+ _bitmapUriList.value = getPhotoUri(planId)
+ }
+ }
+ }
+
+ private fun getPhotoUri(planId: Long): List {
+ val folderPath = "${ImageType.PLAN_IMAGE.filePath}${planId}/"
+ val file = File(folderPath)
+ val photoUri = mutableListOf()
+ file.walk().forEach {
+ if (it.name.endsWith("jpg")) {
+ photoUri.add(Uri.fromFile(it))
+ }
+ }
+ return photoUri
+ }
+
+ fun addParticipant(participantData: SimpleFriendData) {
+ val participants = planParticipants.value?.toMutableSet()
+ participants?.let {
+ it.add(participantData)
+ _planParticipants.value = trimParticipants(it.toList())
+ }
+ }
+
+ fun removeParticipant(index: Int) {
+ val participants = planParticipants.value?.toMutableList()
+ participants?.let {
+ it.removeAt(index)
+ _planParticipants.value = it
+ }
+ }
+
+ fun addParticipantsByGroup(groupId: Long) {
+ if (groupId == -1L) return
+
+ val participantSet = planParticipants.value?.toMutableSet()
+ participantSet?.let { set ->
+ viewModelScope.launch {
+ val friendsInGroup = repository.getSimpleFriendDataListByGroup(groupId)
+ set.addAll(friendsInGroup)
+ _planParticipants.value = trimParticipants(set.toList())
+ }
+ }
+ }
+
+ fun setPlanImageUri(newUriList: List) {
+ val originPlusNewUriList = (_bitmapUriList.value ?: emptyList()) + newUriList
+ if (originPlusNewUriList.size > 5) return // 최대 사진 다섯 장
+ _bitmapUriList.value = originPlusNewUriList
+ }
+
+ private fun trimParticipants(beforeTrimmed: List): List {
+ var afterTrimmed = beforeTrimmed
+
+ if (beforeTrimmed.size > MAX_PARTICIPANTS) {
+ makeSnackbar(R.string.particpants_overload)
+ afterTrimmed = beforeTrimmed.subList(0, MAX_PARTICIPANTS)
+ }
+
+ return afterTrimmed
+ }
+
+ fun setNewDate(newDate: Date) {
+ _planTime.value = newDate
+ }
+
+ fun savePlan(planImageUriList: List) {
+ val participantIds = planParticipants.value?.map { it.id } ?: emptyList()
+ val planDate = planTime.value ?: Date(System.currentTimeMillis())
+ val title = planTitle.value
+ if (title.isNullOrEmpty()) {
+ makeSnackbar(R.string.hint_plan_title)
+ return
+ }
+ val place = planPlace.value ?: ""
+ val content = planContent.value ?: ""
+ val color = "" // TODO: 랜덤 색 만들기
+ val newPlan =
+ if (planId != -1L) PlanData(
+ participantIds,
+ planDate,
+ title,
+ place,
+ content,
+ color,
+ id = planId
+ )
+ else PlanData(participantIds, planDate, title, place, content, color)
+ viewModelScope.launch {
+ saveImage(planImageUriList, repository.getNextPlanId() ?: 0L)
+ planId = repository.savePlanData(newPlan, lastParticipants)
+ reminderMaker.makePlanReminders(
+ SimplePlanData(planId, title, planDate, participantIds)
+ )
+ makeSnackbar(
+ if (planId == -1L) R.string.add_plan_success
+ else R.string.update_plan_success
+ )
+ finish()
+ }
+ }
+
+ private fun saveImage(planImageUriList: List, lastPlanId: Long) {
+ val currentPlanId = if (planId == -1L) { // 기존 사진 수정 시
+ (lastPlanId + 1).toString()
+ } else {
+ planId.toString()
+ }
+ ImageManager.savePlanBitmap(planImageUriList, currentPlanId)
+ }
+
+ private fun makeSnackbar(strId: Int) {
+ _snackbarMessage.value = strId
+ }
+
+ fun finish() {
+ _finishEvent.call()
+ }
+
+ fun addFriend(friendId: Long) {
+ viewModelScope.launch {
+ val friend = repository.getSimpleFriendDataById(friendId)
+ addParticipant(friend)
+ }
+ }
+
+ fun deletePhotoAt(position: Int) {
+ if (position == -1) return
+ val modifiedPhotoUriList = _bitmapUriList.value?.map { it }?.toMutableList()
+ modifiedPhotoUriList?.removeAt(position)
+ _bitmapUriList.postValue(modifiedPhotoUriList ?: emptyList())
+ }
+
+ companion object {
+ const val MAX_PARTICIPANTS = 30
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/FriendAutoCompleteAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/FriendAutoCompleteAdapter.kt
new file mode 100644
index 00000000..8b5501e4
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/FriendAutoCompleteAdapter.kt
@@ -0,0 +1,67 @@
+package com.ivyclub.contact.ui.main.add_edit_plan
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import android.widget.Filter
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemFriendAutoCompleteBinding
+import com.ivyclub.contact.util.binding
+import com.ivyclub.data.model.SimpleFriendData
+
+class FriendAutoCompleteAdapter(
+ context: Context,
+ private val friendList: List,
+) : ArrayAdapter(context, 0, friendList.toMutableList()) {
+
+ private lateinit var binding: ItemFriendAutoCompleteBinding
+
+ private val friendFilter = object : Filter() {
+ override fun performFiltering(inputText: CharSequence?): FilterResults {
+ val results = FilterResults()
+ val suggestions = mutableListOf()
+
+ if (inputText.isNullOrEmpty()) suggestions.addAll(friendList)
+ else {
+ val filterPattern = inputText.toString().lowercase().replace(" ", "")
+
+ suggestions.addAll(
+ friendList.filter {
+ val str = it.name.lowercase().replace(" ", "")
+ str.contains(filterPattern)
+ }
+ )
+ }
+
+ return results.apply {
+ values = suggestions
+ count = suggestions.size
+ }
+ }
+
+ override fun publishResults(inputText: CharSequence?, filterResults: FilterResults?) {
+ clear()
+ addAll(filterResults?.values as List)
+ }
+
+ override fun convertResultToString(resultValue: Any?): CharSequence {
+ return (resultValue as SimpleFriendData).name
+ }
+ }
+
+ override fun getFilter(): Filter {
+ return friendFilter
+ }
+
+ override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
+
+ binding = parent.binding(R.layout.item_friend_auto_complete)
+
+ getItem(position)?.let { data ->
+ binding.friendData = data
+ }
+
+ return binding.root
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/PhotoAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/PhotoAdapter.kt
new file mode 100644
index 00000000..f2df3dda
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/PhotoAdapter.kt
@@ -0,0 +1,73 @@
+package com.ivyclub.contact.ui.main.add_edit_plan
+
+import android.net.Uri
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemImageAtAddPageBinding
+import com.ivyclub.contact.util.binding
+import com.ivyclub.contact.util.clicks
+import com.ivyclub.contact.util.throttleFist
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class PhotoAdapter(
+ private val xButtonClickListener: (Int) -> Unit
+) : ListAdapter(DIFF_CALLBACK) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return PhotoViewHolder(
+ parent.binding(R.layout.item_image_at_add_page),
+ xButtonClickListener
+ )
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ (holder as PhotoViewHolder).bind(getItem(position))
+ }
+
+ @FlowPreview
+ @ExperimentalCoroutinesApi
+ class PhotoViewHolder(
+ private val binding: ItemImageAtAddPageBinding,
+ xButtonClickListener: (Int) -> Unit
+ ) :
+ RecyclerView.ViewHolder(binding.root) {
+ init {
+ binding.circleXImageView.clicks()
+ .throttleFist(700)
+ .onEach {
+ xButtonClickListener.invoke(adapterPosition)
+ }.launchIn(CoroutineScope(Dispatchers.IO))
+ }
+
+ fun bind(photoUri: Uri) {
+ binding.ivImage.setImageURI(photoUri)
+ }
+ }
+
+ companion object {
+ private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: Uri,
+ newItem: Uri
+ ): Boolean {
+ return oldItem === newItem
+ }
+
+ override fun areContentsTheSame(
+ oldItem: Uri,
+ newItem: Uri
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/PhotoData.kt b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/PhotoData.kt
new file mode 100644
index 00000000..a02174e3
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/add_edit_plan/PhotoData.kt
@@ -0,0 +1,5 @@
+package com.ivyclub.contact.ui.main.add_edit_plan
+
+data class PhotoData(
+ val id: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendFragment.kt
new file mode 100644
index 00000000..d0f77eb2
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendFragment.kt
@@ -0,0 +1,220 @@
+package com.ivyclub.contact.ui.main.friend
+
+import android.os.Bundle
+import android.view.Gravity
+import android.view.View
+import android.widget.PopupMenu
+import androidx.activity.OnBackPressedCallback
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentFriendBinding
+import com.ivyclub.contact.ui.main.friend.dialog.GroupDialogFragment
+import com.ivyclub.contact.ui.main.friend.dialog.SelectGroupFragment
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.changeVisibilityWithDirection
+import com.ivyclub.contact.util.hideKeyboard
+import com.ivyclub.contact.util.showKeyboard
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class FriendFragment : BaseFragment(R.layout.fragment_friend) {
+
+ private val viewModel: FriendViewModel by viewModels()
+ private val onBackPressedCallback by lazy {
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ when {
+ viewModel.isSearchViewVisible.value -> {
+ viewModel.setSearchViewVisibility()
+ initFriendList()
+ }
+ viewModel.isInLongClickedState.value -> {
+ friendListAdapter.setAllClickedClear(viewModel.longClickedId)
+ viewModel.clearLongClickedId()
+ }
+ else -> {
+ requireActivity().finish()
+ }
+ }
+ }
+ }
+ }
+ private val friendListAdapter: FriendListAdapter by lazy {
+ FriendListAdapter(
+ onGroupClick = viewModel::manageGroupFolded,
+ onFriendClick = this::navigateToFriendDetailFragment,
+ onFriendLongClick = viewModel::setLongClickedId
+ )
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ binding.viewModel = viewModel
+ initBackPressedCallback()
+ initAddButton()
+ initSettingsButton()
+ initClearButton()
+ initFriendListAdapter()
+ observeSearchViewVisibility()
+ observeFriendList()
+ getGroupSelectFragmentResult()
+ }
+
+ override fun onDetach() {
+ super.onDetach()
+ onBackPressedCallback.remove()
+ }
+
+ private fun initBackPressedCallback() {
+ requireActivity().onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
+ }
+
+ private fun initAddButton() = with(binding) {
+ ivAddFriendIcon.setOnClickListener {
+ val popupMenu = PopupMenu(requireContext(), it)
+ val menuInflater = popupMenu.menuInflater
+ if (friendListAdapter.isOneOfItemLongClicked()) {
+ menuInflater.inflate(R.menu.menu_set_friends_at_friendlist, popupMenu.menu)
+ popupMenu.setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.item_move_friends_to -> {
+ SelectGroupFragment().show(
+ childFragmentManager,
+ SelectGroupFragment.TAG
+ )
+ }
+ }
+ false
+ }
+ } else {
+ menuInflater.inflate(R.menu.menu_friend_and_group, popupMenu.menu)
+ popupMenu.setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.item_new_friend -> {
+ findNavController().navigate(R.id.action_navigation_friend_to_addFriendFragment)
+ }
+ R.id.item_new_group -> {
+ showDialog()
+ }
+ }
+ false
+ }
+ }
+ popupMenu.show()
+ }
+ }
+
+ private fun initSettingsButton() = with(binding) {
+ ivSettingsIcon.setOnClickListener {
+ findNavController().navigate(R.id.action_navigation_friend_to_settingsFragment)
+ }
+ }
+
+ private fun initClearButton() = with(binding) {
+ ivRemoveEt.setOnClickListener {
+ etSearch.setText("")
+ initFriendList()
+ }
+ }
+
+ private fun showDialog() {
+ GroupDialogFragment().show(childFragmentManager, ADD_GROUP_DIALOG_TAG)
+ }
+
+ private fun initFriendListAdapter() {
+ binding.rvFriendList.adapter = friendListAdapter
+ }
+
+ private fun observeSearchViewVisibility() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.isSearchViewVisible.collect { newVisibilityState ->
+ with(binding) {
+ if (newVisibilityState) { // 안보이던 상황에서 -> 보이던 상황으로 될 때
+ showKeyboard()
+ etSearch.changeVisibilityWithDirection(
+ Gravity.TOP,
+ View.VISIBLE,
+ ANIMATION_TIME,
+ this@FriendFragment::requestFocus
+ )
+ } else { // 보이던 상황에서 -> 안보이던 상황으로 될 때
+ hideKeyboard()
+ etSearch.changeVisibilityWithDirection(
+ Gravity.TOP,
+ View.GONE,
+ ANIMATION_TIME
+ )
+ etSearch.text.clear()
+ ivRemoveEt.visibility = View.GONE
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun requestFocus() {
+ binding.etSearch.requestFocus()
+ }
+
+ private fun observeFriendList() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.friendList.collect { newFriendList ->
+ // 새로운 리스트로 리사이클러뷰 갱신
+ friendListAdapter.submitList(newFriendList) {
+ binding.rvFriendList.scrollToPosition(0)
+ }
+ }
+ }
+ }
+ }
+
+ private fun navigateToFriendDetailFragment(friendId: Long) {
+ findNavController().navigate(
+ FriendFragmentDirections.actionNavigationFriendToFriendDetailFragment(
+ friendId
+ )
+ )
+ }
+
+ private fun getGroupSelectFragmentResult() {
+ childFragmentManager.setFragmentResultListener("requestKey", this) { key, bundle ->
+ val result = bundle.getLong("bundleKey")
+ viewModel.updateFriendsGroup(result) // 뷰모델에서 클릭 된 아이템 처리 해제
+ friendListAdapter.clearLongClickedItemCount() // 리스트 어댑터에서 클릭 된 아이템 처리 해제
+ Snackbar.make(
+ binding.root,
+ getString(R.string.friend_fragment_moved_successfully),
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ private fun initFriendList() {
+ friendListAdapter.submitList(viewModel.getOrderedEntireFriendList()) {
+ binding.rvFriendList.scrollToPosition(0)
+ }
+ }
+
+ fun onAddNewGroup(message: String) {
+ Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ onBackPressedCallback.remove()
+ }
+
+ companion object {
+ private const val ANIMATION_TIME = 150L
+ private const val ADD_GROUP_DIALOG_TAG = "AddGroupDialog"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendListAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendListAdapter.kt
new file mode 100644
index 00000000..2464a320
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendListAdapter.kt
@@ -0,0 +1,188 @@
+package com.ivyclub.contact.ui.main.friend
+
+import android.util.Log
+import android.view.ViewGroup
+import android.widget.ImageView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.Glide
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemFriendProfileBinding
+import com.ivyclub.contact.databinding.ItemGroupDividerBinding
+import com.ivyclub.contact.databinding.ItemGroupNameBinding
+import com.ivyclub.contact.model.FriendListData
+import com.ivyclub.contact.util.FriendListViewType
+import com.ivyclub.contact.util.binding
+import com.ivyclub.contact.util.setCustomBackgroundColor
+import com.ivyclub.contact.util.setCustomBackgroundDrawable
+import java.io.File
+
+class FriendListAdapter(
+ private val onGroupClick: (String) -> Unit,
+ private val onFriendClick: (Long) -> Unit,
+ private val onFriendLongClick: (Boolean, Long) -> Unit
+) :
+ ListAdapter(DIFF_CALLBACK) {
+
+ private var longClickedItemCount = 0 // 클릭된 아이템 개수를 확인하는 변수, 0이면 하나도 없는 것
+ private val clickedGroupNameList = mutableListOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ return when (viewType) {
+ FriendListViewType.GROUP_NAME.ordinal ->
+ GroupNameViewHolder(
+ parent.binding(R.layout.item_group_name),
+ onGroupClick,
+ clickedGroupNameList
+ )
+ FriendListViewType.GROUP_DIVIDER.ordinal ->
+ GroupDividerViewHolder(parent.binding(R.layout.item_group_divider))
+ else ->
+ FriendViewHolder(
+ parent.binding(R.layout.item_friend_profile),
+ onFriendClick,
+ this::setLongClicked,
+ this::isOneOfItemLongClicked,
+ onFriendLongClick
+ )
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ val currentItem = getItem(position)
+ when (currentItem.viewType) {
+ FriendListViewType.GROUP_NAME -> (holder as GroupNameViewHolder).bind(currentItem.groupName)
+ FriendListViewType.GROUP_DIVIDER -> (holder as GroupDividerViewHolder)
+ FriendListViewType.FRIEND -> (holder as FriendViewHolder).bind(currentItem)
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return getItem(position).viewType.ordinal
+ }
+
+ // 아이템 중 하나라도 클릭 된 것이 있는지 확인하는 함수
+ fun isOneOfItemLongClicked(): Boolean {
+ return longClickedItemCount != 0
+ }
+
+ fun setAllClickedClear(clickedIdList: List) {
+ for (currentId in clickedIdList) {
+ val targetItemIndex = currentList.indexOfFirst { it.id == currentId }
+ if (targetItemIndex == -1) continue // 없을 경우 1반환
+ getItem(targetItemIndex).isColored = false
+ notifyItemChanged(targetItemIndex)
+ }
+ clearLongClickedItemCount()
+ }
+
+ fun clearLongClickedItemCount() {
+ longClickedItemCount = 0
+ }
+
+ private fun setLongClicked(isLongClicked: Boolean) {
+ if (isLongClicked) longClickedItemCount++
+ else longClickedItemCount--
+ }
+
+ class GroupNameViewHolder(
+ private val binding: ItemGroupNameBinding,
+ private val onGroupClick: (String) -> Unit,
+ private val clickedGroupNameList: MutableList
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ lateinit var groupName: String
+
+ init {
+ binding.ivFolder.setOnClickListener {
+ if (clickedGroupNameList.contains(groupName)) {
+ (it as ImageView).setCustomBackgroundDrawable(R.drawable.ic_baseline_keyboard_arrow_down_24)
+ clickedGroupNameList.remove(groupName)
+ } else {
+ (it as ImageView).setCustomBackgroundDrawable(R.drawable.ic_baseline_keyboard_arrow_up_24)
+ clickedGroupNameList.add(groupName)
+ }
+ if (this::groupName.isInitialized) onGroupClick.invoke(groupName)
+ else Log.e(this::class.java.simpleName, "groupName has not been initialized")
+// if (clickedGroupNameList.contains(groupName)) it.setRotateAnimation(0F, 180F)
+// else it.setRotateAnimation(180F, 0F) // 애니메이션 추후에 추가하기
+ }
+ }
+
+ fun bind(groupName: String) {
+ binding.groupName = groupName
+ this.groupName = groupName
+ binding.ivFolder.setCustomBackgroundDrawable(if (clickedGroupNameList.contains(groupName)) R.drawable.ic_baseline_keyboard_arrow_up_24 else R.drawable.ic_baseline_keyboard_arrow_down_24)
+ }
+ }
+
+ class FriendViewHolder(
+ private val binding: ItemFriendProfileBinding,
+ private val onFriendClick: (Long) -> Unit,
+ private val setLongClicked: (Boolean) -> Unit, // 어댑터에서도 알 수 있도록 해주는 함수
+ private val isOneOfItemLongClicked: () -> Boolean, // 다른 아이템 중 하나라도 long clicked 되어 있는지 확인하는 함수
+ private val transferClickedIdToViewModel: (Boolean, Long) -> Unit
+ ) : RecyclerView.ViewHolder(binding.root) {
+ private lateinit var currentItem: FriendListData
+
+ init {
+ onFriendClick()
+ onFriendLongClick()
+ }
+
+ fun bind(friendItemData: FriendListData) {
+ binding.root.setCustomBackgroundColor(if (friendItemData.isColored) R.color.blue_100 else R.color.white) // 배경색 변경
+ binding.data = friendItemData
+ this.currentItem = friendItemData
+ }
+
+ private fun onFriendClick() = with(binding.root) {
+ setOnClickListener {
+ // 롱 클릭 되어 있는 상태에서 누르는 것인지 구분
+ if (isOneOfItemLongClicked.invoke()) {
+ currentItem.isColored = !(currentItem.isColored) // 현재 아이템이 클릭된 아이템으로 명시
+ setLongClicked(currentItem.isColored) // 롱클릭된 아이템 하나 추가
+ setCustomBackgroundColor(if (currentItem.isColored) R.color.blue_100 else R.color.white) // 배경색 변경
+ transferClickedIdToViewModel(currentItem.isColored, currentItem.id)
+ } else { // 롱 클릭 하는 중이 아니라면
+ onFriendClick.invoke(currentItem.id) // 해당 친구 리스트로 이동
+ }
+ }
+ }
+
+ private fun onFriendLongClick() {
+ binding.root.setOnLongClickListener {
+ if(currentItem.isColored) return@setOnLongClickListener true
+ currentItem.isColored = true
+ setLongClicked(true)
+ it.setCustomBackgroundColor(R.color.blue_100)
+ transferClickedIdToViewModel(true, currentItem.id)
+ true // true로 반환하면 click이벤트는 무시하고, longClick 이벤트만 적용하는 것
+ }
+ }
+ }
+
+ class GroupDividerViewHolder(
+ binding: ItemGroupDividerBinding
+ ) : RecyclerView.ViewHolder(binding.root)
+
+ companion object {
+ private val DIFF_CALLBACK = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: FriendListData,
+ newItem: FriendListData
+ ): Boolean {
+ return oldItem.phoneNumber == newItem.phoneNumber
+ }
+
+ override fun areContentsTheSame(
+ oldItem: FriendListData,
+ newItem: FriendListData
+ ): Boolean {
+ return oldItem == newItem
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendViewModel.kt
new file mode 100644
index 00000000..b2846940
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend/FriendViewModel.kt
@@ -0,0 +1,236 @@
+package com.ivyclub.contact.ui.main.friend
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.model.FriendListData
+import com.ivyclub.contact.util.FriendListViewType
+import com.ivyclub.contact.util.StringManager
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.FriendData
+import com.ivyclub.data.model.GroupData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FriendViewModel @Inject constructor(
+ private val repository: ContactRepository,
+) : ViewModel() {
+
+ private val _isSearchViewVisible = MutableStateFlow(false)
+ val isSearchViewVisible = _isSearchViewVisible.asStateFlow()
+ private val _friendList = MutableStateFlow>(emptyList())
+ val friendList = _friendList.asStateFlow()
+ private val _isClearButtonVisible = MutableStateFlow(false)
+ val isClearButtonVisible = _isClearButtonVisible.asStateFlow()
+ private val _isInLongClickedState = MutableStateFlow(false)
+ val isInLongClickedState = _isInLongClickedState.asStateFlow()
+ private val _isFriendDatabaseEmpty = MutableStateFlow(false)
+ val isFriendDatabaseEmpty = _isFriendDatabaseEmpty.asStateFlow()
+
+ val longClickedId = mutableListOf()
+ private var searchInputString = ""
+ private var originEntireFriendList =
+ emptyList() // 다른 뷰홀더는 없고 친구들만 있는 데이터, 즐겨찾기 때문에 중복이 있음.
+ private var friendListForSearch =
+ emptyList() // 검색했을 때 보여주기 위한 친구 데이터, 즐겨찾기 중복 없음.
+ private var orderedEntireFriendList = emptyList() // 모든 뷰타입으로 정렬된 전체 친구 데이터
+ private val foldedGroupNameList = mutableListOf()
+ private val groupData = mutableMapOf() // group id, group name
+
+ init {
+ getFriendDataWithFlow()
+ getGroupNameData()
+ }
+
+ // DB에서 친구 목록 가져와서 그룹 별로 친구 추가
+ fun getFriendDataWithFlow() {
+ viewModelScope.launch {
+ repository.loadFriendsWithFlow()
+ .combineTransform(repository.loadGroupsWithFlow()) { newFriendList, newGroupList ->
+ resetGroupData(newGroupList)
+ emit(newFriendList.toFriendListData())
+ }
+ .buffer()
+ .collect { newFriendList ->
+ modifyToListType(newFriendList)
+ }
+ }
+ }
+
+ fun onEditTextClicked(inputString: CharSequence) {
+ searchInputString = inputString.toString()
+ setClearButtonVisibility(inputString.toString())
+ if (searchInputString.isEmpty()) { // 입력한 글자가 없다면 원래 리스트로 교체
+ _friendList.value = orderedEntireFriendList
+ return
+ }
+ sortNameWith(inputString.toString())
+ }
+
+ fun setSearchViewVisibility() {
+ _isSearchViewVisible.value = !(_isSearchViewVisible.value)
+ setClearButtonVisibility(searchInputString)
+ }
+
+ fun manageGroupFolded(groupName: String) {
+ if (foldedGroupNameList.contains(groupName)) { // 그룹 다시 펼치기
+ foldedGroupNameList.remove(groupName)
+ val groupIndex = getGroupIndex(groupName) ?: return
+ val newList = generateNewList(groupName, groupIndex)
+ _friendList.value = newList
+ } else { // 그룹 접기
+ foldedGroupNameList.add(groupName)
+ val newList =
+ _friendList.value.filterNot { it.groupName == groupName && it.viewType == FriendListViewType.FRIEND }
+ _friendList.value = newList
+ }
+ }
+
+ // 클릭이 되었으면 true, 해제되었으면 false로 넘어온다.
+ // isAdd가 true면 삽입, false면 제거
+ fun setLongClickedId(isAdd: Boolean, friendId: Long) {
+ if (isAdd) {
+ longClickedId.add(friendId)
+ } else {
+ longClickedId.remove(friendId)
+ }
+ _isInLongClickedState.value = longClickedId.isNotEmpty()
+ }
+
+ fun updateFriendsGroup(groupID: Long?) {
+ if (groupID == null) return
+ viewModelScope.launch {
+ repository.updateGroupOf(longClickedId, groupID)
+ initLongClickedId() // 그룹 이동이 끝나서 저장된 값들 초기화
+ getFriendDataWithFlow() // 리스트 업데이트
+ clearLongClickedId() // long clicked된 id 값들 처리 해제
+ }
+ }
+
+ fun clearLongClickedId() {
+ longClickedId.clear()
+ _isInLongClickedState.value = longClickedId.isNotEmpty()
+ }
+
+ fun getOrderedEntireFriendList() = orderedEntireFriendList
+
+ private fun initLongClickedId() {
+ longClickedId.clear()
+ }
+
+ private fun sortNameWith(inputString: String) {
+ val sortedList =
+ friendListForSearch.filter { it.name.contains(inputString) }.toMutableList()
+ if (inputString.isEmpty()) {
+ _friendList.value = friendListForSearch
+ } else {
+ _friendList.value = sortedList
+ }
+ }
+
+ // 검색창이 내려와있고, 텍스트가 입력된 상황이라면 X 버튼을 활성화
+ private fun setClearButtonVisibility(inputString: String) {
+ _isClearButtonVisible.value = _isSearchViewVisible.value == true && inputString.isNotEmpty()
+ }
+
+ // 중간에 그룹 뷰 데이터를 넣어주는 함수
+ private fun MutableList.addGroupView(): MutableList {
+ if (this.isEmpty()) return this
+ for (index in this.size - 1 downTo 0) {
+ val friendData = this[index]
+ if (index == 0) { // 처음은 무조건 groupName으로 시작해야 한다.
+ this.add(index, getGroupData(this[index].groupName))
+ } else if (index > 0) {
+ val nextFriendData = this[index - 1]
+ // 만약 다음 친구와 서로 다른 그룹에 있다면,
+ // 이전 그룹과 분리하기 위해 선을 긋고, 그룹 뷰를 추가한다.
+ if (friendData.groupName != nextFriendData.groupName) {
+ this.add(index, getGroupData(friendData.groupName))
+ this.add(index, getGroupDividerData())
+ }
+ }
+ }
+ this.add(this.size, getGroupDividerData())
+ return this
+ }
+
+ private fun getGroupData(groupName: String): FriendListData {
+ return FriendListData(groupName = groupName, viewType = FriendListViewType.GROUP_NAME)
+ }
+
+ private fun getGroupDividerData(): FriendListData {
+ return FriendListData(viewType = FriendListViewType.GROUP_DIVIDER)
+ }
+
+ // 리사이클러 뷰에 맞는 데이터 클래스로 변경
+ private fun List.toFriendListData(): List {
+ val convertedFriendList = mutableListOf()
+ this.forEach {
+ val changedData = FriendListData(
+ id = it.id,
+ phoneNumber = it.phoneNumber,
+ name = it.name,
+ groupName = groupData[it.groupId] ?: StringManager.getString("친구"), // default로 친구에 저장
+ viewType = FriendListViewType.FRIEND,
+ isFavoriteFriend = it.isFavorite
+ )
+ convertedFriendList.add(changedData)
+ }
+ return convertedFriendList.toList()
+ }
+
+ private fun getGroupIndex(groupName: String): Int? {
+ _friendList.value.forEachIndexed { index, friendListData ->
+ if (friendListData.groupName == groupName && friendListData.viewType == FriendListViewType.GROUP_NAME) {
+ return index + 1
+ }
+ }
+ return null
+ }
+
+ private fun generateNewList(groupName: String, groupIndex: Int): List {
+ if (_friendList.value.isEmpty()) return emptyList()
+ val firstPart = _friendList.value.subList(0, groupIndex)
+ val middlePart =
+ originEntireFriendList.filter { it.groupName == groupName && it.viewType == FriendListViewType.FRIEND }
+ val lastPart =
+ _friendList.value.subList(groupIndex, _friendList.value.size)
+ return firstPart + middlePart + lastPart
+ }
+
+ private fun getGroupNameData() {
+ viewModelScope.launch {
+ val newList = repository.loadGroupsWithFlow().first()
+ resetGroupData(newList)
+ }
+ }
+
+ private fun resetGroupData(groupList: List) {
+ groupData.clear()
+ groupList.forEach { newGroupData ->
+ groupData[newGroupData.id] = newGroupData.name
+ }
+ }
+
+ private fun modifyToListType(friendList: List) {
+ _isFriendDatabaseEmpty.value = friendList.isEmpty()
+ val favoriteFriendsListData =
+ friendList.filter { it.isFavoriteFriend }.map { it.copy() }
+ favoriteFriendsListData.forEach { it.groupName = StringManager.getString("즐겨찾기") }
+ val definedFriendList =
+ friendList.groupBy { it.groupName }.toSortedMap().values.flatten()
+ .filterNot { it.groupName == StringManager.getString("친구") }.toMutableList() // 그룹 지정이 된 친구 리스트
+ val undefinedFriendList =
+ friendList.filter { it.groupName == StringManager.getString("친구") } // 그룹 지정이 되지 않은 친구 리스트
+ val sortedFriendList =
+ (favoriteFriendsListData + definedFriendList + undefinedFriendList).toMutableList()
+ val newFriendList = sortedFriendList.addGroupView()
+ _friendList.value = newFriendList
+ originEntireFriendList =
+ favoriteFriendsListData + definedFriendList + undefinedFriendList
+ friendListForSearch = definedFriendList + undefinedFriendList
+ orderedEntireFriendList = newFriendList
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/GroupDialogFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/GroupDialogFragment.kt
new file mode 100644
index 00000000..ae4932d5
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/GroupDialogFragment.kt
@@ -0,0 +1,102 @@
+package com.ivyclub.contact.ui.main.friend.dialog
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.viewModels
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.DialogGroupBinding
+import com.ivyclub.contact.ui.main.friend.FriendFragment
+import com.ivyclub.contact.ui.main.settings.group.ManageGroupFragment
+import com.ivyclub.data.model.GroupData
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class GroupDialogFragment(private val groupData: GroupData? = null) : DialogFragment() {
+
+ private var _binding: DialogGroupBinding? = null
+ private val binding get() = _binding ?: error("binding이 초기화되지 않았습니다.")
+ private val dialogViewModel: GroupDialogViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ isCancelable = true
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ _binding = DataBindingUtil.inflate(inflater, R.layout.dialog_group, container, false)
+ binding.viewModel = dialogViewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ if (groupData != null) {
+ dialogViewModel.setBeforeGroupName(groupData.name)
+ with(binding) {
+ tvBeforeGroupName.text = String.format(
+ getString(R.string.format_group_dialog_before_group_name),
+ groupData.name
+ )
+ tvAddGroupTitle.text = getString(R.string.group_dialog_name_edit)
+ btnAddNewGroup.text = getString(R.string.group_dialog_edit)
+ }
+ }
+
+ dialog?.window?.setLayout(
+ ConstraintLayout.LayoutParams.MATCH_PARENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+
+ with(binding) {
+ btnCancel.setOnClickListener {
+ dismiss()
+ }
+
+ btnAddNewGroup.setOnClickListener {
+ val groupName = etNewGroupName.text.toString()
+ if (groupData != null) {
+ dialogViewModel.updateGroupName(groupData.id, groupName)
+ } else {
+ dialogViewModel.saveGroupData(groupName)
+ }
+ onPositiveButtonClick(groupName)
+ dismiss()
+ }
+ }
+ }
+
+ private fun onPositiveButtonClick(newGroupName: String) {
+ if (groupData != null) {
+ val parentFragment = parentFragment as ManageGroupFragment
+ parentFragment.onEditGroupName(
+ String.format(
+ getString(R.string.manage_group_success_edit_name),
+ newGroupName
+ )
+ )
+ } else {
+ val parentFragment = parentFragment as FriendFragment
+ parentFragment.onAddNewGroup(
+ String.format(
+ getString(R.string.group_dialog_success_add_new_group),
+ newGroupName
+ )
+ )
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/GroupDialogViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/GroupDialogViewModel.kt
new file mode 100644
index 00000000..dafbdce2
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/GroupDialogViewModel.kt
@@ -0,0 +1,81 @@
+package com.ivyclub.contact.ui.main.friend.dialog
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.R
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.GroupData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class GroupDialogViewModel @Inject constructor(private val repository: ContactRepository) :
+ ViewModel() {
+
+ private val groups = mutableListOf()
+ private var beforeGroupName = ""
+ private val _groupNameValidation = MutableLiveData(R.string.group_name_validation_wrong_empty)
+ val groupNameValidation: LiveData get() = _groupNameValidation
+ private val _isAddGroupButtonActive = MutableLiveData(false)
+ val isAddGroupButtonActive: LiveData = _isAddGroupButtonActive
+
+ init {
+ getGroupData()
+ }
+
+ private fun getGroupData() {
+ viewModelScope.launch {
+ val groupNameList = repository.loadGroups().map { it.name }
+ groups.clear()
+ groups.addAll(groupNameList)
+ }
+ }
+
+ fun setBeforeGroupName(beforeName: String) {
+ beforeGroupName = beforeName
+ }
+
+ fun saveGroupData(groupName: String) {
+ viewModelScope.launch {
+ repository.saveNewGroup(GroupData(groupName))
+ }
+ }
+
+ fun checkGroupNameValid(text: String) {
+ when {
+ text.isEmpty() -> {
+ _groupNameValidation.value = R.string.group_name_validation_wrong_empty
+ setAddGroupButtonActive(false)
+ }
+ 10 < text.length -> {
+ _groupNameValidation.value = R.string.group_name_validation_wrong_too_long
+ setAddGroupButtonActive(false)
+ }
+ text in groups -> {
+ if (beforeGroupName.isNotEmpty() && beforeGroupName == text) {
+ _groupNameValidation.value = R.string.group_name_validation_wrong_same
+ } else {
+ _groupNameValidation.value = R.string.group_name_validation_wrong_duplicate
+ }
+ setAddGroupButtonActive(false)
+ }
+ else -> {
+ _groupNameValidation.value = R.string.empty_string
+ setAddGroupButtonActive(true)
+ }
+ }
+ }
+
+ private fun setAddGroupButtonActive(isActive: Boolean) {
+ _isAddGroupButtonActive.value = isActive
+ }
+
+ fun updateGroupName(id: Long, name: String) {
+ viewModelScope.launch {
+ repository.updateGroupName(id, name)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/SelectGroupFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/SelectGroupFragment.kt
new file mode 100644
index 00000000..9ac005a6
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/SelectGroupFragment.kt
@@ -0,0 +1,82 @@
+package com.ivyclub.contact.ui.main.friend.dialog
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import androidx.core.os.bundleOf
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.setFragmentResult
+import androidx.fragment.app.viewModels
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentSelectGroupDialogBinding
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SelectGroupFragment : DialogFragment() {
+
+ private lateinit var binding: FragmentSelectGroupDialogBinding
+ private val viewModel: SelectGroupViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ isCancelable = true // 뒤로가기 클릭시 취소
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = DataBindingUtil.inflate(
+ inflater,
+ R.layout.fragment_select_group_dialog,
+ container,
+ false
+ )
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.lifecycleOwner = viewLifecycleOwner
+ initMoveButton()
+ initCancelButton()
+ observerGroupList()
+ }
+
+ private fun initMoveButton() {
+ binding.tvSelect.setOnClickListener {
+ val result = viewModel.getIDOf(binding.spnGroup.selectedItem.toString())
+ setFragmentResult("requestKey", bundleOf("bundleKey" to result))
+ dismiss()
+ }
+ }
+
+ private fun initCancelButton() {
+ binding.tvCancel.setOnClickListener {
+ dismiss()
+ }
+ }
+
+ private fun observerGroupList() {
+ viewModel.groupNameList.observe(viewLifecycleOwner) { newGroupNameList ->
+ setSpinnerAdapter(newGroupNameList)
+ }
+ }
+
+ private fun setSpinnerAdapter(groupNameList: List) {
+ val spinnerAdapter = ArrayAdapter(
+ requireContext(),
+ android.R.layout.simple_spinner_dropdown_item,
+ groupNameList
+ )
+ binding.spnGroup.adapter = spinnerAdapter
+ }
+
+ companion object {
+ const val TAG = "SELECT_GROUP_FRAGMENT"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/SelectGroupViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/SelectGroupViewModel.kt
new file mode 100644
index 00000000..45d88830
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend/dialog/SelectGroupViewModel.kt
@@ -0,0 +1,41 @@
+package com.ivyclub.contact.ui.main.friend.dialog
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.GroupData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SelectGroupViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ private val _groupNameList = MutableLiveData>()
+ val groupNameList: LiveData> get() = _groupNameList
+ private val _groupData = MutableLiveData>()
+ val groupData: LiveData> get() = _groupData
+ private val groupDataMap = mutableMapOf()
+
+ init {
+ getGroupNameListFromDatabase()
+ }
+
+ fun getIDOf(groupName: String): Long? {
+ return groupDataMap[groupName]
+ }
+
+ private fun getGroupNameListFromDatabase() {
+ viewModelScope.launch {
+ _groupData.value = repository.loadGroups()
+ _groupNameList.value = _groupData.value?.map { it.name }
+ _groupData.value?.forEach {
+ groupDataMap[it.name] = it.id
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendAllPlanFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendAllPlanFragment.kt
new file mode 100644
index 00000000..774d1217
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendAllPlanFragment.kt
@@ -0,0 +1,80 @@
+package com.ivyclub.contact.ui.main.friend_detail
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentFriendAllPlanBinding
+import com.ivyclub.contact.ui.plan_list.PlanListAdapter
+import com.ivyclub.contact.ui.plan_list.PlanListHeaderItemDecoration
+import com.ivyclub.contact.util.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class FriendAllPlanFragment :
+ BaseFragment(R.layout.fragment_friend_all_plan) {
+ private val viewModel: FriendAllPlanViewModel by viewModels()
+ private val args: FriendAllPlanFragmentArgs by navArgs()
+
+ private val planListAdapter: PlanListAdapter by lazy {
+ PlanListAdapter {
+ findNavController().navigate(
+ FriendAllPlanFragmentDirections.actionFriendAllPlanFragmentToPlanDetailsFragment(it)
+ )
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.viewModel = viewModel
+
+ initToolbarButtons()
+ initRecyclerView()
+ observePlanListItems()
+
+ args.friendId.let { viewModel.getMyPlans(it) }
+ }
+
+ private fun initToolbarButtons() {
+ with(binding) {
+ ivAddPlanIcon.setOnClickListener {
+ findNavController().navigate(
+ FriendAllPlanFragmentDirections.actionFriendAllPlanFragmentToAddEditFragment(
+ friendId = args.friendId
+ )
+ )
+ }
+ ivBackIcon.setOnClickListener {
+ findNavController().popBackStack()
+ }
+ }
+ }
+
+ private fun initRecyclerView() {
+ binding.rvPlanList.apply {
+ if (adapter == null) {
+ adapter = planListAdapter
+
+ addItemDecoration(PlanListHeaderItemDecoration(object :
+ PlanListHeaderItemDecoration.SectionCallback {
+ override fun isHeader(position: Int) =
+ planListAdapter.isHeader(position)
+
+ override fun getHeaderView(list: RecyclerView, position: Int) =
+ planListAdapter.getHeaderView(list, position)
+ }))
+ }
+ }
+ }
+
+ private fun observePlanListItems() {
+ viewModel.planListItems.observe(viewLifecycleOwner) {
+ planListAdapter.submitList(it)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendAllPlanViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendAllPlanViewModel.kt
new file mode 100644
index 00000000..831f0ef6
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendAllPlanViewModel.kt
@@ -0,0 +1,64 @@
+package com.ivyclub.contact.ui.main.friend_detail
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.ui.plan_list.PlanListItemViewModel
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.SimplePlanData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class FriendAllPlanViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+ private val _planListItems = MutableLiveData>()
+ val planListItems: LiveData> = _planListItems
+ private val _friendName = MutableLiveData()
+ val friendName: LiveData = _friendName
+
+ private val friendMap = mutableMapOf()
+
+ private val loadFriendsJob: Job = viewModelScope.launch {
+ val myFriends = repository.getSimpleFriendData()
+ myFriends.forEach {
+ friendMap[it.id] = it.name
+ }
+ }
+
+ fun getMyPlans(friendId: Long) {
+ viewModelScope.launch {
+ loadFriendsJob.join()
+
+ val friend = repository.getFriendDataById(friendId)
+ val myPlanList = repository.getPlansByIds(friend.planList).sortedBy { it.date }
+ val planItems = mutableListOf()
+ _friendName.value = friend.name
+
+ myPlanList.forEach { planData ->
+ val friends = mutableListOf()
+ planData.participant.forEach { friendId ->
+ friendMap[friendId]?.let { friendName ->
+ friends.add(friendName)
+ }
+ }
+ planItems.add(
+ PlanListItemViewModel(
+ SimplePlanData(
+ planData.id,
+ planData.title,
+ planData.date,
+ planData.participant
+ ), friends
+ )
+ )
+ }
+
+ _planListItems.value = planItems
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendDetailFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendDetailFragment.kt
new file mode 100644
index 00000000..14e5551d
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendDetailFragment.kt
@@ -0,0 +1,164 @@
+package com.ivyclub.contact.ui.main.friend_detail
+
+import android.content.Intent
+import android.graphics.Color
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.view.animation.AnimationUtils
+import android.widget.LinearLayout
+import android.widget.PopupMenu
+import android.widget.TextView
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.FragmentNavigatorExtras
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentFriendDetailBinding
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.showAlertDialog
+import com.ivyclub.data.model.FriendData
+import dagger.hilt.android.AndroidEntryPoint
+import java.text.SimpleDateFormat
+
+@AndroidEntryPoint
+class FriendDetailFragment :
+ BaseFragment(R.layout.fragment_friend_detail) {
+ private val viewModel: FriendDetailViewModel by viewModels()
+ private val args: FriendDetailFragmentArgs by navArgs()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ binding.dateFormat = SimpleDateFormat(getString(R.string.format_simple_date))
+ setObserver()
+ loadFriendDetail(args.friendId)
+ initButtons(args.friendId)
+ }
+
+ private fun loadFriendDetail(id: Long) {
+ viewModel.loadFriendData(id)
+ }
+
+ private fun setObserver() {
+ viewModel.friendData.observe(this, {
+ initDetails(it)
+ })
+ viewModel.finishEvent.observe(viewLifecycleOwner) {
+ findNavController().popBackStack()
+ }
+ viewModel.groupName.observe(viewLifecycleOwner) {
+ binding.tvGroup.text = it
+ }
+ viewModel.goPlanDetailsEvent.observe(viewLifecycleOwner) {
+ findNavController().navigate(
+ FriendDetailFragmentDirections.actionFriendDetailFragmentToPlanDetailsFragment(
+ it
+ )
+ )
+ }
+ }
+
+ private fun initButtons(id: Long) {
+ with(binding) {
+ btnFavorite.setOnClickListener {
+ val animation = AnimationUtils.loadAnimation(context, R.anim.star_animation)
+ btnFavorite.startAnimation(animation)
+ this@FriendDetailFragment.viewModel.setFavorite(id, btnFavorite.isChecked)
+ }
+ ivMore.setOnClickListener {
+ val popupMenu = PopupMenu(requireContext(), it)
+ popupMenu.menuInflater.inflate(R.menu.menu_friend_detail, popupMenu.menu)
+ popupMenu.show()
+ popupMenu.setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.item_edit_friend -> {
+ findNavController().navigate(
+ FriendDetailFragmentDirections.actionFriendDetailFragmentToAddEditFriendFragment(
+ args.friendId
+ )
+ )
+ }
+ R.id.item_delete_friend -> {
+ showDeleteFriendDialog()
+ }
+ }
+ false
+ }
+ }
+
+ ivBackIcon.setOnClickListener {
+ findNavController().popBackStack()
+ }
+ tvSeeAllPlan.setOnClickListener {
+ findNavController().navigate(
+ FriendDetailFragmentDirections.actionFriendDetailFragmentToFriendAllPlanFragment(
+ args.friendId
+ )
+ )
+ }
+ ivProfileImage.setOnClickListener {
+ val extras = FragmentNavigatorExtras(
+ ivProfileImage to "secondTransitionName"
+ )
+ val bundle = Bundle()
+ bundle.putLong("friendId", args.friendId)
+ findNavController().navigate(
+ R.id.action_friendDetailFragment_to_imageDetailFragment,
+ bundle, // Bundle of args
+ null, // NavOptions
+ extras
+ )
+ }
+ }
+ }
+
+ private fun initDetails(friend: FriendData) {
+ with(binding) {
+ llExtraInfo.removeAllViews()
+ for (key in friend.extraInfo.keys) {
+ llExtraInfo.addView(getTitle(key))
+ llExtraInfo.addView(getContent(friend.extraInfo[key] ?: ""))
+ }
+ btnCall.setOnClickListener {
+ val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${friend.phoneNumber}"))
+ startActivity(intent)
+ }
+ }
+ }
+
+ private fun getTitle(text: String): TextView {
+ val layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ layoutParams.setMargins(24, 48, 0, 0)
+ return TextView(context).apply {
+ this.text = text
+ textSize = 14f
+ this.layoutParams = layoutParams
+ }
+ }
+
+ private fun getContent(text: String): TextView {
+ val layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ )
+ layoutParams.setMargins(0, 24, 0, 0)
+ return TextView(context).apply {
+ this.text = text
+ setTextColor(Color.BLACK)
+ textSize = 16f
+ this.layoutParams = layoutParams
+ setBackgroundResource(R.drawable.bg_details)
+ setPadding(32, 24, 32, 24)
+ }
+ }
+
+ private fun showDeleteFriendDialog() {
+ context?.showAlertDialog(getString(R.string.ask_delete_friend), {
+ viewModel.deleteFriend(args.friendId)
+ })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendDetailViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendDetailViewModel.kt
new file mode 100644
index 00000000..65e06c03
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/FriendDetailViewModel.kt
@@ -0,0 +1,73 @@
+package com.ivyclub.contact.ui.main.friend_detail
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.image.ImageManager
+import com.ivyclub.data.model.FriendData
+import com.ivyclub.data.model.PlanData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import java.util.*
+import javax.inject.Inject
+
+@HiltViewModel
+class FriendDetailViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ private val _friendData = MutableLiveData()
+ val friendData: LiveData get() = _friendData
+ private val _groupName = MutableLiveData()
+ val groupName: LiveData get() = _groupName
+
+ private val _lastPlans = MutableLiveData>()
+ val lastPlans: LiveData> = _lastPlans
+
+ private val _goPlanDetailsEvent = SingleLiveEvent()
+ val goPlanDetailsEvent: LiveData = _goPlanDetailsEvent
+
+ private val _finishEvent = SingleLiveEvent()
+ val finishEvent: LiveData = _finishEvent
+
+ fun loadFriendData(id: Long) {
+ viewModelScope.launch {
+ val friend = repository.getFriendDataById(id)
+ _friendData.value = friend
+ _groupName.value = repository.getGroupNameById(friend.groupId)
+ loadPlans(friend.planList)
+ }
+ }
+
+ fun setFavorite(id: Long, state: Boolean) {
+ viewModelScope.launch {
+ repository.setFavorite(id, state)
+ }
+ }
+
+ private suspend fun loadPlans(planIds: List) {
+ val plans = repository.getPlansByIds(planIds).filter { it.date < Date() }
+ .sortedByDescending { it.date }
+ _lastPlans.value = plans
+ }
+
+ fun deleteFriend(friendId: Long) {
+ ImageManager.deleteImage(friendId)
+ viewModelScope.launch {
+ repository.deleteFriend(friendId)
+ finish()
+ }
+ }
+
+ fun goPlanDetails(planId: Long) {
+ if (planId == -1L) return
+ _goPlanDetailsEvent.value = planId
+ }
+
+ private fun finish() {
+ _finishEvent.call()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/ImageDetailFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/ImageDetailFragment.kt
new file mode 100644
index 00000000..18a8b6d9
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/friend_detail/ImageDetailFragment.kt
@@ -0,0 +1,47 @@
+package com.ivyclub.contact.ui.main.friend_detail
+
+import android.os.Bundle
+import android.transition.ChangeBounds
+import android.view.View
+import androidx.navigation.fragment.findNavController
+import com.bumptech.glide.Glide
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentImageDetailBinding
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.data.image.ImageManager
+
+
+class ImageDetailFragment :
+ BaseFragment(R.layout.fragment_image_detail) {
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ arguments?.getLong("friendId")?.let {
+ initPage(it)
+ }
+ initCloseButton()
+ }
+
+ private fun initPage(friendId: Long) {
+ ImageManager.loadProfileImage(friendId)?.let {
+ Glide.with(binding.ivProfileImage)
+ .load(it)
+ .into(binding.ivProfileImage)
+ } ?: Glide.with(binding.ivProfileImage)
+ .load(R.drawable.photo)
+ .into(binding.ivProfileImage)
+
+ sharedElementEnterTransition = ChangeBounds().apply {
+ duration = 300
+ }
+ sharedElementReturnTransition = ChangeBounds().apply {
+ duration = 300
+ }
+ }
+
+ private fun initCloseButton() {
+ binding.ivBtnClose.setOnClickListener {
+ findNavController().popBackStack()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/plan/PlanFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/plan/PlanFragment.kt
new file mode 100644
index 00000000..965c0835
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/plan/PlanFragment.kt
@@ -0,0 +1,73 @@
+package com.ivyclub.contact.ui.main.plan
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentPlanBinding
+import com.ivyclub.contact.ui.plan_list.PlanListAdapter
+import com.ivyclub.contact.ui.plan_list.PlanListHeaderItemDecoration
+import com.ivyclub.contact.util.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class PlanFragment : BaseFragment(R.layout.fragment_plan) {
+
+ private val viewModel: PlanViewModel by viewModels()
+
+ private val planListAdapter: PlanListAdapter by lazy {
+ PlanListAdapter {
+ findNavController().navigate(
+ PlanFragmentDirections.actionNavigationPlanToPlanDetailsFragment(it)
+ )
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.viewModel = viewModel
+
+ initToolbarButtons()
+ initRecyclerView()
+ observePlanListItems()
+ viewModel.refreshPlanItems()
+ }
+
+ private fun initToolbarButtons() {
+ with(binding) {
+ ivAddPlanIcon.setOnClickListener {
+ findNavController().navigate(PlanFragmentDirections.actionNavigationPlanToAddEditFragment())
+ }
+
+ ivSettingsIcon.setOnClickListener {
+ findNavController().navigate(R.id.action_navigation_plan_to_settingsFragment)
+ }
+ }
+ }
+
+ private fun initRecyclerView() {
+ binding.rvPlanList.apply {
+ if (adapter == null) {
+ adapter = planListAdapter
+
+ addItemDecoration(PlanListHeaderItemDecoration(object :
+ PlanListHeaderItemDecoration.SectionCallback {
+ override fun isHeader(position: Int) =
+ planListAdapter.isHeader(position)
+
+ override fun getHeaderView(list: RecyclerView, position: Int) =
+ planListAdapter.getHeaderView(list, position)
+ }))
+ }
+ }
+ }
+
+ private fun observePlanListItems() {
+ viewModel.planListItems.observe(viewLifecycleOwner) {
+ planListAdapter.submitList(it)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/plan/PlanViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/plan/PlanViewModel.kt
new file mode 100644
index 00000000..d12d8c60
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/plan/PlanViewModel.kt
@@ -0,0 +1,85 @@
+package com.ivyclub.contact.ui.main.plan
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.ui.plan_list.PlanListItemViewModel
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.SimplePlanData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.buffer
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.transform
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class PlanViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ private val _loading = MutableLiveData()
+ val loading: LiveData = _loading
+
+ private val _planListItems = MutableLiveData>()
+ val planListItems: LiveData> = _planListItems
+
+ private var planListSnapshot = emptyList()
+
+ init { getMyPlans() }
+
+ private fun getMyPlans() {
+ viewModelScope.launch {
+ _loading.value = true
+
+ repository.loadPlanListWithFlow().buffer()
+ .transform { planList ->
+ planListSnapshot = planList
+ emit(planList.mapToPlanItemList(setFriendMap()))
+ }.collect { planItemViewModels ->
+ _planListItems.value = planItemViewModels
+ _loading.value = false
+ }
+ }
+ }
+
+ fun refreshPlanItems() {
+ val previousItems = planListItems.value
+ if (previousItems.isNullOrEmpty()) return
+
+ viewModelScope.launch {
+ val newItems = planListSnapshot.mapToPlanItemList(setFriendMap())
+ if (previousItems != newItems) {
+ _planListItems.value = newItems
+ }
+ }
+ }
+
+ private suspend fun setFriendMap(): Map {
+ val friendMap = mutableMapOf()
+ repository.getSimpleFriendData()?.forEach {
+ friendMap[it.id] = it.name
+ }
+ return friendMap
+ }
+
+ private fun List.mapToPlanItemList(friendMap: Map)
+ : List {
+ val planItems = mutableListOf()
+
+ forEach { planData ->
+ val friends = mutableListOf()
+ planData.participant.forEach { friendId ->
+ friendMap[friendId]?.let { friendName ->
+ friends.add(friendName)
+ }
+ }
+ planItems.add(
+ PlanListItemViewModel(planData, friends)
+ )
+ }
+
+ return planItems
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/ParticipantInfoBottomSheetFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/ParticipantInfoBottomSheetFragment.kt
new file mode 100644
index 00000000..0d60a6bc
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/ParticipantInfoBottomSheetFragment.kt
@@ -0,0 +1,96 @@
+package com.ivyclub.contact.ui.main.plan_details
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.setFragmentResult
+import androidx.fragment.app.viewModels
+import com.bumptech.glide.Glide
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentParticipantInfoBinding
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ParticipantInfoBottomSheetFragment : BottomSheetDialogFragment() {
+
+ private lateinit var binding: FragmentParticipantInfoBinding
+ private val viewModel: ParticipantInfoViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = DataBindingUtil.inflate(
+ inflater,
+ R.layout.fragment_participant_info,
+ container,
+ false
+ )
+
+ with(binding) {
+ lifecycleOwner = viewLifecycleOwner
+ viewModel = this@ParticipantInfoBottomSheetFragment.viewModel
+ }
+
+ arguments?.getLong(KEY_PARTICIPANT_ID)?.let { participantId ->
+ viewModel.getParticipantData(participantId)
+ }
+
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setBottomSheetBehavior(view)
+ initButtons()
+ setObservers()
+ }
+
+ private fun setBottomSheetBehavior(view: View) {
+ BottomSheetBehavior.from(view.parent as View).apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ peekHeight = BottomSheetBehavior.PEEK_HEIGHT_AUTO
+ }
+ }
+
+ private fun initButtons() {
+ with(binding) {
+ ivBtnClose.setOnClickListener { dismiss() }
+ tvBtnGoDetails.setOnClickListener {
+ arguments?.let { args -> setFragmentResult(REQUEST, args) }
+ dismiss()
+ }
+ ivBtnCall.setOnClickListener {
+ this@ParticipantInfoBottomSheetFragment.viewModel.getParticipantPhone()
+ ?.let { phoneNumber ->
+ val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${phoneNumber}"))
+ startActivity(intent)
+ }
+ }
+ }
+ }
+
+ private fun setObservers() {
+ viewModel.participantImage.observe(viewLifecycleOwner) {
+ binding.ivProfileImage.clipToOutline = true
+ Glide.with(this)
+ .load(it)
+ .placeholder(R.drawable.photo)
+ .into(binding.ivProfileImage)
+ }
+ }
+
+ companion object {
+ const val TAG = "PARTICIPANT_INFO_FRAGMENT"
+ const val KEY_PARTICIPANT_ID = "participantId"
+ const val REQUEST = "REQUEST_PARTICIPANT_INFO"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/ParticipantInfoViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/ParticipantInfoViewModel.kt
new file mode 100644
index 00000000..5232d224
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/ParticipantInfoViewModel.kt
@@ -0,0 +1,52 @@
+package com.ivyclub.contact.ui.main.plan_details
+
+import android.graphics.Bitmap
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.image.ImageManager
+import com.ivyclub.data.model.FriendData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@HiltViewModel
+class ParticipantInfoViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ private val _participantData = MutableLiveData()
+ val participantData: LiveData = _participantData
+
+ private val _participantGroup = MutableLiveData()
+ val participantGroup: LiveData = _participantGroup
+
+ private val _participantImage = MutableLiveData()
+ val participantImage: LiveData = _participantImage
+
+ fun getParticipantData(participantId: Long) {
+ if (participantId == -1L) return
+
+ viewModelScope.launch {
+ val data = repository.getFriendDataById(participantId)
+ _participantData.value = data
+ val groupName = repository.getGroupNameById(data.groupId)
+ _participantGroup.value = groupName
+ _participantImage.value = getParticipantImage(participantId)
+ }
+ }
+
+ private suspend fun getParticipantImage(participantId: Long) =
+ withContext(Dispatchers.IO) {
+ ImageManager.loadProfileImage(participantId)
+ }
+
+ fun getParticipantPhone(): String? {
+ val data = participantData.value ?: return null
+ return if (data.phoneNumber.isEmpty()) null else data.phoneNumber
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PhotoAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PhotoAdapter.kt
new file mode 100644
index 00000000..c03912e7
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PhotoAdapter.kt
@@ -0,0 +1,36 @@
+package com.ivyclub.contact.ui.main.plan_details
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemPlanPhotoBinding
+import com.ivyclub.data.image.ImageType
+
+class PhotoAdapter(private val photoList: List, private val planId: Long) :
+ RecyclerView.Adapter() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): PagerViewHolder {
+ val layoutInflater = LayoutInflater.from(parent.context)
+ val binding: ItemPlanPhotoBinding =
+ DataBindingUtil.inflate(layoutInflater, R.layout.item_plan_photo, parent, false)
+ return PagerViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: PagerViewHolder, position: Int) {
+ holder.bind(photoList[position], planId)
+ }
+
+ override fun getItemCount(): Int = photoList.size
+
+ inner class PagerViewHolder(private val binding: ItemPlanPhotoBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(imageName: String, planId: Long) {
+ binding.imageString = "${ImageType.PLAN_IMAGE.filePath}$planId/$imageName"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PlanDetailsFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PlanDetailsFragment.kt
new file mode 100644
index 00000000..46692c3c
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PlanDetailsFragment.kt
@@ -0,0 +1,182 @@
+package com.ivyclub.contact.ui.main.plan_details
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentPlanDetailsBinding
+import com.ivyclub.contact.ui.main.plan_details.ParticipantInfoBottomSheetFragment.Companion.KEY_PARTICIPANT_ID
+import com.ivyclub.contact.ui.main.plan_details.ParticipantInfoBottomSheetFragment.Companion.REQUEST
+import com.ivyclub.contact.ui.main.plan_details.PlanDetailsViewModel.Companion.KEY_PHONE_NUMBERS
+import com.ivyclub.contact.ui.main.plan_details.PlanDetailsViewModel.Companion.KEY_PLAN_CONTENT
+import com.ivyclub.contact.ui.main.plan_details.PlanDetailsViewModel.Companion.KEY_PLAN_PLACE
+import com.ivyclub.contact.ui.main.plan_details.PlanDetailsViewModel.Companion.KEY_PLAN_TIME
+import com.ivyclub.contact.ui.main.plan_details.PlanDetailsViewModel.Companion.KEY_PLAN_TITLE
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.setFriendChips
+import com.ivyclub.contact.util.showAlertDialog
+import dagger.hilt.android.AndroidEntryPoint
+import java.sql.Date
+import java.text.SimpleDateFormat
+import java.util.*
+
+@AndroidEntryPoint
+class PlanDetailsFragment :
+ BaseFragment(R.layout.fragment_plan_details) {
+
+ private val viewModel: PlanDetailsViewModel by viewModels()
+ private val args: PlanDetailsFragmentArgs by navArgs()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.viewModel = viewModel
+ binding.dateFormat = SimpleDateFormat(getString(R.string.format_simple_date), Locale.getDefault())
+
+ fetchPlanDetails()
+ setObservers()
+ setEditPlanButton()
+ setParticipantInfoResultLauncher()
+ }
+
+ private fun setEditPlanButton() {
+ with(binding) {
+ ivBtnBack.setOnClickListener { findNavController().popBackStack() }
+
+ ivBtnEditPlan.setOnClickListener {
+ findNavController().navigate(
+ PlanDetailsFragmentDirections.actionPlanDetailsFragmentToAddEditFragment(
+ args.planId
+ )
+ )
+ }
+
+ ivBtnDeletePlan.setOnClickListener {
+ showDeletePlanDialog()
+ }
+
+ ivBtnSharePlanToParticipants.setOnClickListener {
+ this@PlanDetailsFragment.viewModel.sendMessagesToPlanParticipants()
+ }
+ }
+ }
+
+ private fun showDeletePlanDialog() {
+ context?.showAlertDialog(getString(R.string.ask_delete_plan), {
+ viewModel.deletePlan()
+ })
+ }
+
+ private fun setObservers() {
+ with(viewModel) {
+ planParticipants.observe(viewLifecycleOwner) { participants ->
+ binding.cgPlanParticipants.setFriendChips(participants.map { it.name }) { index ->
+ viewModel.goParticipantsDetails(index)
+ }
+ }
+
+ goFriendDetailsEvent.observe(viewLifecycleOwner) { friendId ->
+ showParticipantInfoDialog(friendId)
+ }
+
+ sendMessagesToParticipantsEvent.observe(viewLifecycleOwner) { bundle ->
+ sendSharePlanMessages(bundle)
+ }
+
+ snackbarMessage.observe(viewLifecycleOwner) {
+ if (context == null) return@observe
+ Snackbar.make(binding.root, getString(it), Snackbar.LENGTH_SHORT).show()
+ }
+
+ finishEvent.observe(viewLifecycleOwner) {
+ findNavController().popBackStack()
+ }
+
+ folderExists.observe(viewLifecycleOwner) {
+ if(it) { viewModel.getPhotos(args.planId) }
+ }
+
+ photoIds.observe(viewLifecycleOwner) {
+ with(binding) {
+ vpPhoto.adapter = PhotoAdapter(it, args.planId)
+ vpPhoto.orientation = ViewPager2.ORIENTATION_HORIZONTAL
+ if (it.isNotEmpty()) vpPhoto.currentItem = 0
+ sdicIndicator.setViewPager2(vpPhoto)
+ }
+ }
+ }
+ }
+
+ private fun showParticipantInfoDialog(participantId: Long) {
+ val bundle = Bundle().apply {
+ putLong(KEY_PARTICIPANT_ID, participantId)
+ }
+ ParticipantInfoBottomSheetFragment().apply {
+ arguments = bundle
+ showsDialog = true
+ show(
+ this@PlanDetailsFragment.childFragmentManager,
+ ParticipantInfoBottomSheetFragment.TAG
+ )
+ }
+ }
+
+ private fun setParticipantInfoResultLauncher() {
+ childFragmentManager.setFragmentResultListener(REQUEST, viewLifecycleOwner) { _, bundle ->
+ findNavController().navigate(
+ PlanDetailsFragmentDirections
+ .actionPlanDetailsFragmentToFriendDetailFragment(
+ bundle.getLong(
+ KEY_PARTICIPANT_ID
+ )
+ )
+ )
+ }
+ }
+
+ private fun sendSharePlanMessages(bundle: Bundle) {
+ val strTo = bundle.getString(KEY_PHONE_NUMBERS)
+
+ val planTitle = bundle.getString(KEY_PLAN_TITLE) ?: return
+ val msgPlanTitle = String.format(getString(R.string.format_share_plan_title), planTitle)
+
+ val planTime = bundle.getLong(KEY_PLAN_TIME, -1L)
+ if (planTime == -1L) return
+ val strPlanTime =
+ SimpleDateFormat(getString(R.string.format_simple_date), Locale.getDefault()).format(Date(planTime))
+ val msgPlanTime = String.format(getString(R.string.format_share_plan_time), strPlanTime)
+
+ val planPlace = bundle.getString(KEY_PLAN_PLACE)
+ val msgPlanPlace =
+ if (planPlace == null) ""
+ else String.format(getString(R.string.format_share_plan_place), planPlace)
+
+ val planContent = bundle.getString(KEY_PLAN_CONTENT)
+ val msgPlanContent =
+ if (planContent == null) ""
+ else String.format(getString(R.string.format_share_plan_place), planContent)
+
+ val smsIntent = Intent(Intent.ACTION_SENDTO, Uri.parse(strTo))
+ .apply {
+ putExtra(
+ "sms_body",
+ String.format(
+ getString(R.string.format_share_plan_to_participants),
+ msgPlanTitle + msgPlanTime + msgPlanPlace + msgPlanContent,
+ getString(R.string.app_name)
+ )
+ )
+ }
+ startActivity(smsIntent)
+ }
+
+ private fun fetchPlanDetails() {
+ viewModel.getPlanDetails(args.planId)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PlanDetailsViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PlanDetailsViewModel.kt
new file mode 100644
index 00000000..10fc4ea0
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/plan_details/PlanDetailsViewModel.kt
@@ -0,0 +1,157 @@
+package com.ivyclub.contact.ui.main.plan_details
+
+import android.os.Bundle
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.R
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.image.ImageManager
+import com.ivyclub.data.image.ImageType
+import com.ivyclub.data.model.PlanData
+import com.ivyclub.data.model.SimpleFriendData
+import com.ivyclub.data.model.SimplePlanData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+
+@HiltViewModel
+class PlanDetailsViewModel @Inject constructor(
+ private val repository: ContactRepository,
+ private val reminderMaker: PlanReminderMaker
+) : ViewModel() {
+
+ private val _planDetails = MutableLiveData()
+ val planDetails: LiveData = _planDetails
+
+ private val _planParticipants = MutableLiveData>()
+ val planParticipants: LiveData> = _planParticipants
+
+ private val _snackbarMessage = SingleLiveEvent()
+ val snackbarMessage: LiveData = _snackbarMessage
+
+ private val _goFriendDetailsEvent = SingleLiveEvent()
+ val goFriendDetailsEvent: LiveData = _goFriendDetailsEvent
+
+ private val _sendMessagesToParticipantsEvent = SingleLiveEvent()
+ val sendMessagesToParticipantsEvent: LiveData = _sendMessagesToParticipantsEvent
+
+ private val _finishEvent = SingleLiveEvent()
+ val finishEvent: LiveData = _finishEvent
+
+ private val _folderExists = MutableLiveData()
+ val folderExists: LiveData get() = _folderExists
+
+ private val _photoIds = MutableLiveData>(emptyList())
+ val photoIds: LiveData> get() = _photoIds
+
+ private val friendMap = mutableMapOf()
+
+ private val loadFriendsJob: Job = viewModelScope.launch {
+ val myFriends = repository.getSimpleFriendData()
+ myFriends.forEach {
+ friendMap[it.id] = it
+ }
+ }
+
+ fun getPlanDetails(planId: Long) {
+ viewModelScope.launch {
+ val planData = repository.getPlanDataById(planId)
+ val removedFriendsIds = mutableListOf()
+
+ loadFriendsJob.join()
+ val friends = mutableListOf()
+ planData.participant.forEach { friendId ->
+ val friendData = friendMap[friendId]
+ if (friendData == null) removedFriendsIds.add(friendId)
+ else friends.add(friendData)
+ }
+
+ _planParticipants.value = friends
+ _planDetails.value = planData
+
+ checkFolderExists(planData.id)
+
+ if (removedFriendsIds.isNotEmpty()) {
+ repository.updatePlansParticipants(planData.participant - removedFriendsIds, planId)
+ }
+ }
+ }
+
+ private fun checkFolderExists(planId: Long) {
+ val folderPath = "${ImageType.PLAN_IMAGE.filePath}${planId}/"
+ val file = File(folderPath)
+ _folderExists.value = file.exists()
+ }
+
+ fun getPhotos(planId: Long) {
+ val folderPath = "${ImageType.PLAN_IMAGE.filePath}${planId}/"
+ val file = File(folderPath)
+ val photos = mutableListOf()
+ file.walk().forEach {
+ if (it.name.endsWith("jpg")) photos.add(it.name)
+ }
+ _photoIds.value = photos
+ }
+
+ fun goParticipantsDetails(index: Int) {
+ val participants = _planParticipants.value ?: return
+ _goFriendDetailsEvent.value = participants[index].id
+ }
+
+ fun sendMessagesToPlanParticipants() {
+ val participants = planParticipants.value ?: return
+ val planData = planDetails.value ?: return
+
+ val bundle = Bundle()
+
+ val phoneNumbers =
+ participants
+ .filter { it.phoneNumber.isNotEmpty() }
+ .map { it.phoneNumber }
+
+ val strReceivers = "smsto:" + phoneNumbers.reduce { acc, s -> "$acc;$s" }
+
+ bundle.putString(KEY_PHONE_NUMBERS, strReceivers)
+ bundle.putString(KEY_PLAN_TITLE, planData.title)
+ bundle.putLong(KEY_PLAN_TIME, planData.date.time)
+ if (planData.place.isNotEmpty()) bundle.putString(KEY_PLAN_PLACE, planData.place)
+ if (planData.content.isNotEmpty()) bundle.putString(KEY_PLAN_CONTENT, planData.content)
+
+ _sendMessagesToParticipantsEvent.value = bundle
+ }
+
+ fun deletePlan() {
+ val planData = planDetails.value ?: return
+ viewModelScope.launch {
+ ImageManager.deletePlanImageFolder(planData.id.toString())
+ repository.deletePlanData(planData)
+ reminderMaker.cancelPlanReminder(
+ SimplePlanData(planData.id, planData.title, planData.date, planData.participant)
+ )
+ makeSnackbar(R.string.delete_plan_success)
+ finish()
+ }
+ }
+
+ private fun makeSnackbar(strId: Int) {
+ _snackbarMessage.value = strId
+ }
+
+ private fun finish() {
+ _finishEvent.call()
+ }
+
+ companion object {
+ const val KEY_PHONE_NUMBERS = "phone_numbers"
+ const val KEY_PLAN_TITLE = "plan_title"
+ const val KEY_PLAN_TIME = "plan_time"
+ const val KEY_PLAN_PLACE = "plan_place"
+ const val KEY_PLAN_CONTENT = "plan_content"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/SettingsFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/SettingsFragment.kt
new file mode 100644
index 00000000..93f1bf15
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/SettingsFragment.kt
@@ -0,0 +1,54 @@
+package com.ivyclub.contact.ui.main.settings
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentSettingsBinding
+import com.ivyclub.contact.ui.main.settings.dialog.NotificationTimeDialogFragment
+import com.ivyclub.contact.util.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SettingsFragment : BaseFragment(R.layout.fragment_settings) {
+ private val viewModel: SettingsViewModel by viewModels()
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initViewModel()
+ initBackIcon()
+ initSettingButton()
+ }
+
+ private fun initViewModel() {
+ binding.viewModel = viewModel
+ }
+
+ private fun initBackIcon() {
+ binding.ivBackIcon.setOnClickListener {
+ val navController = findNavController()
+ navController.popBackStack()
+ }
+ }
+
+ private fun initSettingButton() {
+ binding.tvGetContacts.setOnClickListener {
+ findNavController().navigate(R.id.action_settingsFragment_to_settingsContactFragment)
+ }
+ binding.tvNotificationTime.setOnClickListener {
+ val notificationTimeDialog = NotificationTimeDialogFragment()
+ notificationTimeDialog.show(childFragmentManager, NotificationTimeDialogFragment.TAG)
+ }
+ binding.tvSecurity.setOnClickListener {
+ findNavController().navigate(R.id.action_settingsFragment_to_securityFragment)
+ }
+ binding.tvManageGroup.setOnClickListener {
+ findNavController().navigate(R.id.action_settingsFragment_to_manageGroupFragment)
+ }
+ binding.tvOssLicense.setOnClickListener {
+ findNavController().navigate(R.id.action_settingsFragment_to_licenseFragment)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/SettingsViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/SettingsViewModel.kt
new file mode 100644
index 00000000..ef342937
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/SettingsViewModel.kt
@@ -0,0 +1,26 @@
+package com.ivyclub.contact.ui.main.settings
+
+import androidx.lifecycle.ViewModel
+import com.ivyclub.data.ContactPreference
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
+ private val preference: ContactPreference
+) : ViewModel() {
+ val isAlarmActive = MutableStateFlow(false)
+
+ init {
+ checkAlarmActivation()
+ }
+
+ fun setAlarmActivationOfSwitch() {
+ preference.setNotificationOnOff(!isAlarmActive.value)
+ }
+
+ private fun checkAlarmActivation() {
+ isAlarmActive.value = preference.getNotificationState()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/AddContactFromSettingViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/AddContactFromSettingViewModel.kt
new file mode 100644
index 00000000..0847d981
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/AddContactFromSettingViewModel.kt
@@ -0,0 +1,63 @@
+package com.ivyclub.contact.ui.main.settings.contact
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.model.PhoneContactData
+import com.ivyclub.contact.util.ContactListManager
+import com.ivyclub.contact.util.ContactSavingUiState
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.FriendData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AddContactFromSettingViewModel @Inject constructor(
+ private val contactListManager: ContactListManager,
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ private val _loadingUiState = MutableStateFlow(ContactSavingUiState.Empty)
+ val loadingUIState = _loadingUiState.asStateFlow()
+ val contactList = mutableListOf()
+
+ init {
+ getContactList()
+ }
+
+ fun saveFriends(friendData: List) {
+ viewModelScope.launch {
+ _loadingUiState.value = ContactSavingUiState.Dialog
+ friendData.forEach {
+ repository.saveFriend(
+ FriendData(
+ it.phoneNumber,
+ it.name,
+ "",
+ 1,
+ listOf(),
+ false,
+ mapOf()
+ )
+ )
+ }
+ _loadingUiState.value = ContactSavingUiState.DialogDone
+ }
+ }
+
+ private fun getContactList() {
+ val phoneContactList = contactListManager.getContact()
+ viewModelScope.launch {
+ _loadingUiState.value = ContactSavingUiState.Loading
+ val originFriendsList = repository.loadFriends()
+ phoneContactList.forEach { contact ->
+ if (!originFriendsList.any { it.phoneNumber == contact.phoneNumber }) {
+ contactList.add(contact)
+ }
+ }
+ _loadingUiState.value = ContactSavingUiState.LoadingDone
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/AddContactFromSettingsFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/AddContactFromSettingsFragment.kt
new file mode 100644
index 00000000..140ad4f9
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/AddContactFromSettingsFragment.kt
@@ -0,0 +1,136 @@
+package com.ivyclub.contact.ui.main.settings.contact
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentAddContactFromSettingsBinding
+import com.ivyclub.contact.ui.main.friend.dialog.SelectGroupFragment
+import com.ivyclub.contact.ui.onboard.contact.ContactAdapter
+import com.ivyclub.contact.ui.onboard.contact.dialog.DialogGetContactsLoadingFragment
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.ContactSavingUiState
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class AddContactFromSettingsFragment :
+ BaseFragment(R.layout.fragment_add_contact_from_settings) {
+
+ private val viewModel: AddContactFromSettingViewModel by viewModels()
+ private val contactAdapter by lazy { ContactAdapter(this::setCheckbox) }
+ private val loadingDialog by lazy { DialogGetContactsLoadingFragment() }
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ initGrantedView()
+ } else {
+ binding.tvContactPermission.visibility = View.VISIBLE
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ checkPermission()
+ initDefaultButtons()
+ }
+
+ private fun initDefaultButtons() {
+ binding.tvContactPermission.setOnClickListener {
+ val needPermissionDialog = NeedPermissionDialog()
+ needPermissionDialog.show(
+ childFragmentManager,
+ NeedPermissionDialog.TAG
+ )
+ }
+ binding.ivBackIcon.setOnClickListener {
+ findNavController().popBackStack()
+ }
+ }
+
+ private fun initGrantedView() {
+ initButtons()
+ initAdapter()
+ observeLoadingUiState()
+ }
+
+ private fun checkPermission() {
+ if (ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.READ_CONTACTS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ requestContactPermission()
+ } else {
+ initGrantedView()
+ }
+ }
+
+ private fun initButtons() = with(binding) {
+ btnLoad.setOnClickListener {
+ viewModel.saveFriends(contactAdapter.addSet.toList())
+ }
+ cbSelectAll.setOnClickListener {
+ if (cbSelectAll.isChecked) {
+ contactAdapter.selectAllItem()
+ } else {
+ contactAdapter.removeAllItem()
+ }
+ }
+ }
+
+ private fun initAdapter() {
+ binding.rvContactList.adapter = contactAdapter
+ }
+
+ private fun observeLoadingUiState() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.loadingUIState.collect { newState ->
+ when (newState) {
+ ContactSavingUiState.Loading -> {
+ binding.pbLoading.visibility = View.VISIBLE
+ binding.tvWait.visibility = View.VISIBLE
+ }
+ ContactSavingUiState.LoadingDone -> {
+ binding.pbLoading.visibility = View.GONE
+ binding.tvWait.visibility = View.GONE
+ contactAdapter.submitList(viewModel.contactList)
+ }
+ ContactSavingUiState.Dialog -> {
+ loadingDialog.show(
+ childFragmentManager,
+ DialogGetContactsLoadingFragment.TAG
+ )
+ }
+ ContactSavingUiState.DialogDone -> {
+ if (loadingDialog.isVisible) loadingDialog.dismiss()
+ findNavController().popBackStack()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun setCheckbox(state: Boolean) {
+ binding.cbSelectAll.isChecked = state
+ }
+
+ private fun requestContactPermission() {
+ requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/NeedPermissionDialog.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/NeedPermissionDialog.kt
new file mode 100644
index 00000000..d9ff476c
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/contact/NeedPermissionDialog.kt
@@ -0,0 +1,57 @@
+package com.ivyclub.contact.ui.main.settings.contact
+
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.DialogFragment
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentDialogRequestPermissionBinding
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+
+
+class NeedPermissionDialog : DialogFragment() {
+ private lateinit var binding: FragmentDialogRequestPermissionBinding
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ isCancelable = true
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding =
+ DataBindingUtil.inflate(
+ inflater,
+ R.layout.fragment_dialog_request_permission,
+ container,
+ false
+ )
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.btnConfirm.setOnClickListener {
+ val intent = Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", requireContext().packageName, null)
+ )
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(intent)
+ }
+ binding.btnCancel.setOnClickListener {
+ this.dismiss()
+ }
+ }
+
+ companion object {
+ const val TAG = "NEED_PERMISSION_DIALOG"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/dialog/NotificationTimeDialogFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/dialog/NotificationTimeDialogFragment.kt
new file mode 100644
index 00000000..34feb7f9
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/dialog/NotificationTimeDialogFragment.kt
@@ -0,0 +1,93 @@
+package com.ivyclub.contact.ui.main.settings.dialog
+
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.viewModels
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentDialogNotificationTimeBinding
+import dagger.hilt.android.AndroidEntryPoint
+import kotlin.math.roundToInt
+
+@AndroidEntryPoint
+class NotificationTimeDialogFragment : DialogFragment() {
+
+ private lateinit var binding: FragmentDialogNotificationTimeBinding
+ private val viewModel: NotificationTimeDialogViewModel by viewModels()
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+ isCancelable = true
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding =
+ DataBindingUtil.inflate(
+ inflater,
+ R.layout.fragment_dialog_notification_time,
+ container,
+ false
+ )
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ binding.lifecycleOwner = viewLifecycleOwner
+ initRangeSlider()
+ initCancelButton()
+ initConfirmButton()
+ initRadioGroup()
+ observeDismissEvent()
+ }
+
+ private fun initRadioGroup() {
+ viewModel.planNotiTimeIdx.value?.let { i ->
+ binding.rgSetPlanNotiTime.check(binding.rgSetPlanNotiTime.getChildAt(i).id)
+ }
+ }
+
+ private fun observeDismissEvent() {
+ viewModel.changeNotiTimeFinishEvent.observe(viewLifecycleOwner) {
+ Toast.makeText(context, getString(R.string.change_noti_time_finish), Toast.LENGTH_SHORT)
+ .show()
+ dismiss()
+ }
+ }
+
+ private fun initRangeSlider() = with(binding.rsTimeRange) {
+ values = listOf(viewModel.notificationStartTime, viewModel.notificationEndTime)
+ setLabelFormatter { value ->
+ return@setLabelFormatter "${value.roundToInt()}시"
+ }
+ }
+
+ private fun initCancelButton() {
+ binding.tvCancel.setOnClickListener { dismiss() }
+ }
+
+ private fun initConfirmButton() {
+ binding.tvConfirm.setOnClickListener {
+ viewModel.updateNotificationTime(
+ binding.rsTimeRange.values[START_TIME_INDEX],
+ binding.rsTimeRange.values[END_TIME_INDEX]
+ )
+ }
+ }
+
+ companion object {
+ const val TAG = "NOTIFICATION_TIME_DIALOG"
+ private const val START_TIME_INDEX = 0
+ private const val END_TIME_INDEX = 1
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/dialog/NotificationTimeDialogViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/dialog/NotificationTimeDialogViewModel.kt
new file mode 100644
index 00000000..75bcf5ab
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/dialog/NotificationTimeDialogViewModel.kt
@@ -0,0 +1,84 @@
+package com.ivyclub.contact.ui.main.settings.dialog
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.service.plan_reminder.PlanReminderMaker
+import com.ivyclub.contact.util.HOUR_IN_MILLIS
+import com.ivyclub.contact.util.MINUTE_IN_MILLIS
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.contact.util.getNewTime
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import java.sql.Date
+import javax.inject.Inject
+
+@HiltViewModel
+class NotificationTimeDialogViewModel @Inject constructor(
+ private val repository: ContactRepository,
+ private val reminderMaker: PlanReminderMaker
+) : ViewModel() {
+
+ private val friendMap = mutableMapOf()
+ private val loadFriendsJob: Job = viewModelScope.launch {
+ repository.getSimpleFriendData().forEach {
+ friendMap[it.id] = it.name
+ }
+ }
+
+ val notificationStartTime: Float by lazy {
+ repository.getStartAlarmHour().toFloat()
+ }
+ val notificationEndTime: Float by lazy {
+ repository.getEndAlarmHour().toFloat()
+ }
+
+ private val planNotiTimes = arrayOf(15 * MINUTE_IN_MILLIS, 30 * MINUTE_IN_MILLIS, HOUR_IN_MILLIS, 2 * HOUR_IN_MILLIS)
+ private val _planNotiTimeIdx = MutableLiveData()
+ val planNotiTimeIdx: LiveData = _planNotiTimeIdx
+
+ private val _changeNotiTimeFinishEvent = SingleLiveEvent()
+ val changeNotiTimeFinishEvent: LiveData = _changeNotiTimeFinishEvent
+
+ init {
+ initPlanNotiTime()
+ }
+
+ private fun initPlanNotiTime() {
+ var planNotiTime = repository.getPlanNotificationTime()
+ if (planNotiTime == 0L) {
+ planNotiTime = planNotiTimes[2]
+ repository.setPlanNotificationTime(planNotiTime)
+ }
+ planNotiTimes.forEachIndexed { i, time ->
+ if (time == planNotiTime)
+ _planNotiTimeIdx.value = i
+ }
+ }
+
+ fun updatePlanNotiIdx(idx: Int) {
+ _planNotiTimeIdx.value = idx
+ }
+
+ fun updateNotificationTime(startTime: Float, endTime: Float) {
+ viewModelScope.launch {
+ repository.setNotificationTimeRange(startTime.toInt(), endTime.toInt())
+ planNotiTimeIdx.value?.let { repository.setPlanNotificationTime(planNotiTimes[it]) }
+ loadFriendsJob.join()
+ val todayStart = Date(System.currentTimeMillis()).getNewTime(0, 0).time
+ val futurePlanList = repository.getPlanListAfter(todayStart)
+ if (futurePlanList.isNullOrEmpty()) {
+ _changeNotiTimeFinishEvent.call()
+ return@launch
+ }
+ futurePlanList.forEach { simplePlanData ->
+ reminderMaker.makePlanReminders(simplePlanData)
+ }
+
+ _changeNotiTimeFinishEvent.call()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/GroupListAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/GroupListAdapter.kt
new file mode 100644
index 00000000..e7398ac1
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/GroupListAdapter.kt
@@ -0,0 +1,51 @@
+package com.ivyclub.contact.ui.main.settings.group
+
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemGroupListBinding
+import com.ivyclub.contact.util.binding
+import com.ivyclub.data.model.GroupData
+
+class GroupListAdapter(
+ private val onEditButtonClick: (GroupData) -> Unit,
+ private val onDeleteButtonClick: (GroupData) -> Unit
+) : ListAdapter(diffUtil) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupViewHolder {
+ return GroupViewHolder(parent.binding(R.layout.item_group_list))
+ }
+
+ override fun onBindViewHolder(holder: GroupViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ inner class GroupViewHolder(private val binding: ItemGroupListBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ init {
+ binding.ivDeleteGroup.setOnClickListener {
+ onDeleteButtonClick(getItem(adapterPosition))
+ }
+ binding.ivEditGroupName.setOnClickListener {
+ onEditButtonClick(getItem(adapterPosition))
+ }
+ }
+
+ fun bind(groupData: GroupData) {
+ binding.groupData = groupData
+ }
+ }
+
+ companion object {
+ private val diffUtil = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: GroupData, newItem: GroupData): Boolean =
+ oldItem.name == newItem.name
+
+ override fun areContentsTheSame(oldItem: GroupData, newItem: GroupData): Boolean =
+ oldItem == newItem
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/ManageGroupFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/ManageGroupFragment.kt
new file mode 100644
index 00000000..ba6a0b47
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/ManageGroupFragment.kt
@@ -0,0 +1,84 @@
+package com.ivyclub.contact.ui.main.settings.group
+
+import android.app.AlertDialog
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentManageGroupBinding
+import com.ivyclub.contact.ui.main.friend.dialog.GroupDialogFragment
+import com.ivyclub.contact.util.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ManageGroupFragment :
+ BaseFragment(R.layout.fragment_manage_group) {
+
+ private val viewModel: ManageGroupViewModel by viewModels()
+ private val groupListAdapter: GroupListAdapter by lazy {
+ GroupListAdapter(
+ viewModel::showEditDialog,
+ viewModel::showDeleteDialog
+ )
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.rvGroupList.adapter = groupListAdapter
+ observeGroupList()
+ initBackButton()
+ observeShowDialog()
+ }
+
+ private fun initBackButton() {
+ binding.ivBackIcon.setOnClickListener {
+ findNavController().popBackStack()
+ }
+ }
+
+ private fun observeGroupList() {
+ viewModel.groupList.observe(viewLifecycleOwner) {
+ groupListAdapter.submitList(it)
+ }
+ }
+
+ private fun observeShowDialog() {
+ viewModel.showDeleteDialog.observe(viewLifecycleOwner) { group ->
+ if (context != null) {
+ AlertDialog.Builder(requireContext())
+ .setMessage(String.format(getString(R.string.manage_group_delete), group.name))
+ .setPositiveButton(R.string.yes) { _, _ ->
+ viewModel.deleteGroup(group)
+ Snackbar.make(
+ binding.root,
+ String.format(
+ getString(R.string.group_dialog_success_delete_group),
+ group.name
+ ),
+ Snackbar.LENGTH_SHORT
+ ).show()
+ }
+ .setNegativeButton(R.string.no) { _, _ ->
+
+ }
+ .show()
+ }
+ }
+
+ viewModel.showEditDialog.observe(viewLifecycleOwner) { group ->
+ val editDialog = GroupDialogFragment(group)
+ editDialog.show(childFragmentManager, EDIT_GROUP_NAME_DIALOG_TAG)
+ }
+ }
+
+ fun onEditGroupName(message: String) {
+ Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show()
+ viewModel.loadGroupList()
+ }
+
+ companion object {
+ private const val EDIT_GROUP_NAME_DIALOG_TAG = "EditGroupNameDialog"
+ }
+}
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/ManageGroupViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/ManageGroupViewModel.kt
new file mode 100644
index 00000000..dc07a285
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/group/ManageGroupViewModel.kt
@@ -0,0 +1,57 @@
+package com.ivyclub.contact.ui.main.settings.group
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.GroupData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ManageGroupViewModel @Inject constructor(private val repository: ContactRepository) : ViewModel() {
+
+ private val _groupList = MutableLiveData>()
+ val groupList: LiveData> get() = _groupList
+ private val _showDeleteDialog = SingleLiveEvent()
+ val showDeleteDialog: LiveData get() = _showDeleteDialog
+ private val _showEditDialog = SingleLiveEvent()
+ val showEditDialog: LiveData get() = _showEditDialog
+
+ init {
+ loadGroupList()
+ }
+
+ fun loadGroupList() {
+ viewModelScope.launch {
+ val groups = repository.loadGroups()
+ _groupList.value = groups
+ }
+ }
+
+ fun showDeleteDialog(groupData: GroupData) {
+ _showDeleteDialog.value = groupData
+ }
+
+ fun showEditDialog(groupData: GroupData) {
+ _showEditDialog.value = groupData
+ }
+
+ fun deleteGroup(groupData: GroupData) {
+ viewModelScope.launch {
+ moveToFriendGroup(groupData.id)
+ repository.deleteGroup(groupData)
+ _groupList.value = repository.loadGroups()
+ }
+ }
+
+ private fun moveToFriendGroup(beforeGroupId: Long) {
+ viewModelScope.launch {
+ val friendIdList = repository.getSimpleFriendDataListByGroup(beforeGroupId).map { it.id }
+ repository.updateGroupOf(friendIdList, 1)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/license/LicenseFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/license/LicenseFragment.kt
new file mode 100644
index 00000000..6e240c7a
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/license/LicenseFragment.kt
@@ -0,0 +1,36 @@
+package com.ivyclub.contact.ui.main.settings.license
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.navigation.fragment.findNavController
+import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentLicenseBinding
+import com.ivyclub.contact.util.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class LicenseFragment : BaseFragment(R.layout.fragment_license) {
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initBackIcon()
+ initSettingButton()
+ }
+
+ private fun initBackIcon() {
+ binding.ivBackIcon.setOnClickListener {
+ val navController = findNavController()
+ navController.popBackStack()
+ }
+ }
+
+ private fun initSettingButton() {
+ binding.tvImageLicense.setOnClickListener {
+ findNavController().navigate(R.id.action_licenseFragment_to_imageLicenseFragment)
+ }
+ binding.tvOssLicense.setOnClickListener {
+ startActivity(Intent(requireActivity(), OssLicensesMenuActivity::class.java))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/license/image_license/ImageLicenseFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/license/image_license/ImageLicenseFragment.kt
new file mode 100644
index 00000000..8913e288
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/license/image_license/ImageLicenseFragment.kt
@@ -0,0 +1,26 @@
+package com.ivyclub.contact.ui.main.settings.license.image_license
+
+import android.os.Bundle
+import android.view.View
+import androidx.navigation.fragment.findNavController
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentImageLicenseBinding
+import com.ivyclub.contact.util.BaseFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class ImageLicenseFragment :
+ BaseFragment(R.layout.fragment_image_license) {
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initBackIcon()
+ }
+
+ private fun initBackIcon() {
+ binding.ivBackIcon.setOnClickListener {
+ val navController = findNavController()
+ navController.popBackStack()
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/security/SecurityFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/security/SecurityFragment.kt
new file mode 100644
index 00000000..6242b0f9
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/security/SecurityFragment.kt
@@ -0,0 +1,72 @@
+package com.ivyclub.contact.ui.main.settings.security
+
+import android.os.Bundle
+import android.view.View
+import androidx.biometric.BiometricManager
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentSecurityBinding
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.PasswordViewType
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SecurityFragment : BaseFragment(R.layout.fragment_security) {
+
+ private val viewModel: SecurityViewModel by viewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ viewModel.initSecurityState()
+ observeMoveFragment()
+ initFingerPrintButtonClickListener()
+ }
+
+ private fun initFingerPrintButtonClickListener() {
+ binding.btnFingerPrint.setOnClickListener {
+ if (context != null) {
+ val biometricManager = BiometricManager.from(requireContext())
+ when(biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)) {
+ BiometricManager.BIOMETRIC_SUCCESS -> {
+ viewModel.setFingerPrint()
+ }
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
+ Snackbar.make(binding.root, getString(R.string.biometric_error_hw_unavailable), Snackbar.LENGTH_SHORT).show()
+ }
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
+ Snackbar.make(binding.root, getString(R.string.biometric_error_none_enrolled), Snackbar.LENGTH_SHORT).show()
+ }
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
+ Snackbar.make(binding.root, getString(R.string.biometric_error_hw_unavailable), Snackbar.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+ }
+
+ private fun observeMoveFragment() {
+ viewModel.moveToConfirmPassword.observe(viewLifecycleOwner) { password ->
+ if (password.isNotEmpty()) {
+ findNavController().navigate(
+ SecurityFragmentDirections.actionSecurityFragmentToPasswordFragment(
+ PasswordViewType.SECURITY_CONFIRM_PASSWORD,
+ password
+ )
+ )
+ }
+ }
+ viewModel.moveToSetPassword.observe(viewLifecycleOwner) {
+ findNavController().navigate(
+ SecurityFragmentDirections.actionSecurityFragmentToPasswordFragment(
+ PasswordViewType.SET_PASSWORD
+ )
+ )
+ }
+ viewModel.moveToPreviousFragment.observe(viewLifecycleOwner) {
+ findNavController().popBackStack()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/main/settings/security/SecurityViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/main/settings/security/SecurityViewModel.kt
new file mode 100644
index 00000000..2eb3130b
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/main/settings/security/SecurityViewModel.kt
@@ -0,0 +1,77 @@
+package com.ivyclub.contact.ui.main.settings.security
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SecurityViewModel @Inject constructor(private val repository: ContactRepository) : ViewModel() {
+
+ private var lock = true
+
+ // 데이터바인딩
+ val password = MutableLiveData()
+ val fingerPrint = MutableLiveData()
+
+ private val _moveToSetPassword = SingleLiveEvent()
+ val moveToSetPassword: LiveData get() = _moveToSetPassword
+ private val _moveToConfirmPassword = SingleLiveEvent()
+ val moveToConfirmPassword: LiveData get() = _moveToConfirmPassword
+ private val _moveToPreviousFragment = SingleLiveEvent()
+ val moveToPreviousFragment: LiveData get() = _moveToPreviousFragment
+
+ fun initSecurityState() {
+ viewModelScope.launch {
+ password.value = repository.getPassword()
+ fingerPrint.value = repository.getFingerPrintState()
+ if ((password.value?.isNotEmpty() == true) && lock) {
+ _moveToConfirmPassword.value = password.value
+ unlock()
+ }
+ }
+ }
+
+ fun onPasswordButtonClicked() {
+ if (password.value?.isEmpty() == true) {
+ _moveToSetPassword.call()
+ unlock() // 비밀번호 설정 후에 다시 비밀번호 분류 페이지에 돌아왔을 때 다시 비밀번호 확인하는 과정을 없애기 위해
+ } else {
+ viewModelScope.launch {
+ repository.removePassword()
+ repository.setFingerPrintState(false)
+ initSecurityState()
+ }
+ }
+ }
+
+ fun onResetPasswordButtonClicked() {
+ _moveToSetPassword.call()
+ unlock()
+ }
+
+ private fun unlock() {
+ lock = false
+ }
+
+ fun setFingerPrint() {
+ viewModelScope.launch {
+ if (fingerPrint.value == true) {
+ fingerPrint.value = false
+ repository.setFingerPrintState(false)
+ } else {
+ fingerPrint.value = true
+ repository.setFingerPrintState(true)
+ }
+ }
+ }
+
+ fun moveToPreviousFragment() {
+ _moveToPreviousFragment.call()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/OnBoardingActivity.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/OnBoardingActivity.kt
new file mode 100644
index 00000000..3bf2988e
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/OnBoardingActivity.kt
@@ -0,0 +1,37 @@
+package com.ivyclub.contact.ui.onboard
+
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.navigation.fragment.NavHostFragment
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ActivityOnBoardingBinding
+import com.ivyclub.contact.util.BaseActivity
+import com.ivyclub.contact.util.SkipDialog
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class OnBoardingActivity : BaseActivity(R.layout.activity_on_boarding) {
+
+ private lateinit var navHostFragment: NavHostFragment
+ private val viewModel: OnBoardingViewModel by viewModels()
+
+ private val ok = DialogInterface.OnClickListener { _, _ ->
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ viewModel.saveDefaultGroup()
+ viewModel.setFirstOnBoardingStateFalse()
+ }
+
+ override fun onBackPressed() {
+ navHostFragment = binding.fcvOnBoarding.getFragment() as NavHostFragment
+ if (navHostFragment.childFragmentManager.backStackEntryCount > 0) {
+ super.onBackPressed()
+ } else {
+ SkipDialog(ok, this).showDialog()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/OnBoardingViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/OnBoardingViewModel.kt
new file mode 100644
index 00000000..2ea8f429
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/OnBoardingViewModel.kt
@@ -0,0 +1,29 @@
+package com.ivyclub.contact.ui.onboard
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.util.StringManager
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.GroupData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class OnBoardingViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ fun saveDefaultGroup() {
+ viewModelScope.launch {
+ repository.saveNewGroup(GroupData(StringManager.getString("친구"), 1))
+ }
+ }
+
+ // 첫 실행이 아닌 것을 false로 셋팅
+ fun setFirstOnBoardingStateFalse() {
+ viewModelScope.launch {
+ repository.setShowOnBoardingState(false)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/app_intro/IntroFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/app_intro/IntroFragment.kt
new file mode 100644
index 00000000..08c87056
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/app_intro/IntroFragment.kt
@@ -0,0 +1,43 @@
+package com.ivyclub.contact.ui.onboard.app_intro
+
+import android.os.Bundle
+import android.view.View
+import androidx.navigation.fragment.findNavController
+import androidx.viewpager2.widget.ViewPager2
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentIntroBinding
+import com.ivyclub.contact.util.BaseFragment
+
+class IntroFragment : BaseFragment(R.layout.fragment_intro) {
+ private val introImages =
+ listOf(R.drawable.intro0, R.drawable.intro1, R.drawable.intro2, R.drawable.intro3)
+ private val introString by lazy{
+ listOf(
+ requireContext().getString(R.string.fragment_intro_introduce_string_1),
+ requireContext().getString(R.string.fragment_intro_introduce_string_2),
+ requireContext().getString(R.string.fragment_intro_introduce_string_3),
+ requireContext().getString(R.string.fragment_intro_introduce_string_4),
+ )
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initPage()
+ }
+
+ private fun initPage() {
+ with(binding) {
+ vpIntro.adapter =
+ ViewPagerAdapter(introImages, introString, this@IntroFragment::navigateToNext)
+ vpIntro.orientation = ViewPager2.ORIENTATION_HORIZONTAL
+ sdicIndicator.setViewPager2(binding.vpIntro)
+ tvSkip.setOnClickListener {
+ navigateToNext()
+ }
+ }
+ }
+
+ private fun navigateToNext() {
+ findNavController().navigate(IntroFragmentDirections.actionIntroFragmentToNotificationTimeFragment())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/app_intro/ViewPagerAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/app_intro/ViewPagerAdapter.kt
new file mode 100644
index 00000000..008b6873
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/app_intro/ViewPagerAdapter.kt
@@ -0,0 +1,53 @@
+package com.ivyclub.contact.ui.onboard.app_intro
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.view.isVisible
+import androidx.databinding.DataBindingUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemIntroBinding
+import com.ivyclub.contact.util.setCustomBackgroundDrawable
+import kotlin.reflect.KFunction0
+
+class ViewPagerAdapter(
+ private val introList: List,
+ private val introStringList: List,
+ private val onClicked: KFunction0
+) : RecyclerView.Adapter() {
+ override fun onCreateViewHolder(
+ parent: ViewGroup,
+ viewType: Int
+ ): PagerViewHolder {
+ val layoutInflater = LayoutInflater.from(parent.context)
+ val binding: ItemIntroBinding =
+ DataBindingUtil.inflate(layoutInflater, R.layout.item_intro, parent, false)
+ return PagerViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: PagerViewHolder, position: Int) {
+ holder.bind(introList[position], introStringList[position])
+ holder.setButton(position == 3)
+ }
+
+ override fun getItemCount(): Int = introList.size
+
+ inner class PagerViewHolder(private val binding: ItemIntroBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ init {
+ binding.btnStart.setOnClickListener {
+ onClicked()
+ }
+ }
+
+ fun bind(image: Int, str: String) {
+ binding.ivIntro.setCustomBackgroundDrawable(image)
+ binding.tvIntro.text = str
+ }
+
+ fun setButton(visible: Boolean) {
+ binding.btnStart.isVisible = visible
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/AddContactFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/AddContactFragment.kt
new file mode 100644
index 00000000..4f43373b
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/AddContactFragment.kt
@@ -0,0 +1,161 @@
+package com.ivyclub.contact.ui.onboard.contact
+
+import android.Manifest
+import android.app.Activity.RESULT_OK
+import android.content.DialogInterface
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.AnimationUtils
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.isVisible
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.ui.AppBarConfiguration
+import androidx.navigation.ui.setupWithNavController
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentAddContactBinding
+import com.ivyclub.contact.model.PhoneContactData
+import com.ivyclub.contact.ui.main.MainActivity
+import com.ivyclub.contact.ui.onboard.contact.dialog.DialogGetContactsLoadingFragment
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.ContactSavingUiState
+import com.ivyclub.contact.util.SkipDialog
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class AddContactFragment : BaseFragment(R.layout.fragment_add_contact) {
+
+ private lateinit var contactAdapter: ContactAdapter
+ private lateinit var contactList: MutableList
+ private val viewModel: AddContactViewModel by viewModels()
+ private val navController by lazy { findNavController() }
+ private val loadingDialog = DialogGetContactsLoadingFragment()
+ private val ok = DialogInterface.OnClickListener { _, _ ->
+ activity?.finish()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ setHasOptionsMenu(true)
+ return super.onCreateView(inflater, container, savedInstanceState)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ contactList = mutableListOf()
+ initRecyclerView()
+ initButtons()
+ initAppBar()
+ observeSavingDone()
+ }
+
+ private fun initRecyclerView() {
+ contactAdapter = ContactAdapter(this::setCheckbox)
+ binding.rvContactList.adapter = contactAdapter
+ }
+
+ private fun initButtons() = with(binding) {
+ btnLoad.setOnClickListener {
+ requestPermission()
+ }
+ btnCommit.setOnClickListener {
+ this@AddContactFragment.viewModel.saveFriendsData(contactAdapter.addSet.toMutableList())
+ }
+ btnCommit.isClickable = false
+ cbSelectAll.setOnClickListener {
+ if (cbSelectAll.isChecked) {
+ contactAdapter.selectAllItem()
+ } else {
+ contactAdapter.removeAllItem()
+ }
+ }
+ }
+
+ private fun setCheckbox(state: Boolean) {
+ binding.cbSelectAll.isChecked = state
+ }
+
+ private fun loadContact() {
+ with(binding) {
+ val buttonAnimation = AnimationUtils.loadAnimation(context, R.anim.button_down)
+ val textAnimation = AnimationUtils.loadAnimation(context, R.anim.text_gone)
+ val recyclerViewAnimation =
+ AnimationUtils.loadAnimation(context, R.anim.recyclerview_fade_in)
+ btnLoad.startAnimation(buttonAnimation)
+ tvIntroduce.startAnimation(textAnimation)
+ rvContactList.visibility = View.VISIBLE
+ rvContactList.startAnimation(recyclerViewAnimation)
+ btnLoad.isClickable = false
+ btnLoad.text = getString(R.string.item_intro_start)
+ contactAdapter.submitList(this@AddContactFragment.viewModel.getContactList())
+ btnCommit.isClickable = true
+ binding.cbSelectAll.isVisible = true
+ }
+ }
+
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ loadContact()
+ } else {
+ activity?.finish()
+ }
+ }
+
+ private fun requestPermission() {
+ requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
+ }
+
+ private fun initAppBar() {
+ val appBarConfiguration = AppBarConfiguration(navController.graph)
+ binding.tbAddContact.setupWithNavController(navController, appBarConfiguration)
+ binding.tbAddContact.title = ""
+ binding.tbAddContact.inflateMenu(R.menu.menu_on_boarding_add)
+ binding.tbAddContact.setOnMenuItemClickListener {
+ when (it.itemId) {
+ R.id.skip -> {
+ SkipDialog(ok, context).showDialog()
+ }
+ }
+ true
+ }
+ }
+
+ private fun observeSavingDone() {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.isSavingDone.collect { newState ->
+ when (newState) {
+ ContactSavingUiState.Loading -> {
+ loadingDialog.show(
+ childFragmentManager,
+ DialogGetContactsLoadingFragment.TAG
+ )
+ }
+ ContactSavingUiState.LoadingDone -> {
+ if (loadingDialog.isVisible) loadingDialog.dismiss()
+ val intent = Intent(context, MainActivity::class.java)
+ activity?.setResult(RESULT_OK, intent)
+ activity?.finish()
+ }
+ ContactSavingUiState.Empty -> {
+ }
+ }
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/AddContactViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/AddContactViewModel.kt
new file mode 100644
index 00000000..9693212c
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/AddContactViewModel.kt
@@ -0,0 +1,51 @@
+package com.ivyclub.contact.ui.onboard.contact
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.model.PhoneContactData
+import com.ivyclub.contact.util.ContactListManager
+import com.ivyclub.contact.util.ContactSavingUiState
+import com.ivyclub.data.ContactRepository
+import com.ivyclub.data.model.FriendData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AddContactViewModel @Inject constructor(
+ private val repository: ContactRepository,
+ private val contactListManager: ContactListManager
+) : ViewModel() {
+
+ private val _isSavingDone = MutableStateFlow(ContactSavingUiState.Empty)
+ val isSavingDone = _isSavingDone.asStateFlow()
+
+ fun saveFriendsData(data: List) {
+ if (data.isEmpty()) {
+ _isSavingDone.value = ContactSavingUiState.LoadingDone
+ }
+ viewModelScope.launch {
+ _isSavingDone.value = ContactSavingUiState.Loading
+ data.forEach {
+ repository.saveFriend(
+ FriendData(
+ it.phoneNumber,
+ it.name,
+ "",
+ 1,
+ listOf(),
+ false,
+ mapOf()
+ )
+ )
+ }
+ _isSavingDone.value = ContactSavingUiState.LoadingDone
+ }
+ }
+
+ fun getContactList(): MutableList {
+ return contactListManager.getContact()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/ContactAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/ContactAdapter.kt
new file mode 100644
index 00000000..dc9c9615
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/ContactAdapter.kt
@@ -0,0 +1,72 @@
+package com.ivyclub.contact.ui.onboard.contact
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.databinding.ItemContactBinding
+import com.ivyclub.contact.model.PhoneContactData
+
+class ContactAdapter(
+ private val setCheckboxState: (Boolean) -> Unit
+): ListAdapter(diffUtil) {
+
+ val addSet = mutableSetOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+ ViewHolder(
+ ItemContactBinding.inflate(
+ LayoutInflater.from(parent.context), parent, false
+ )
+ )
+
+ override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ fun selectAllItem() {
+ addSet.clear()
+ currentList.forEach { addSet.add(it) }
+ notifyDataSetChanged()
+ }
+
+ fun removeAllItem() {
+ addSet.clear()
+ notifyDataSetChanged()
+ }
+
+ inner class ViewHolder(
+ private val binding: ItemContactBinding
+ ): RecyclerView.ViewHolder(binding.root) {
+ init {
+ binding.clParent.setOnClickListener {
+ binding.cbAdd.isChecked = !binding.cbAdd.isChecked
+ }
+ binding.cbAdd.setOnCheckedChangeListener { _, checked ->
+ if(checked) {
+ addSet.add(getItem(adapterPosition))
+ } else {
+ addSet.remove(getItem(adapterPosition))
+ }
+ setCheckboxState(addSet.size == currentList.size)
+ }
+ }
+
+ fun bind(data: PhoneContactData) {
+ binding.tvName.text = data.name
+ binding.tvPhoneNum.text = data.phoneNumber
+ binding.cbAdd.isChecked = addSet.contains(data)
+ }
+ }
+
+ companion object {
+ private val diffUtil = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: PhoneContactData, newItem: PhoneContactData) =
+ oldItem.phoneNumber == newItem.phoneNumber
+
+ override fun areContentsTheSame(oldItem: PhoneContactData, newItem: PhoneContactData) =
+ oldItem == newItem
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/dialog/DialogGetContactsLoadingFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/dialog/DialogGetContactsLoadingFragment.kt
new file mode 100644
index 00000000..2072dbd0
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/contact/dialog/DialogGetContactsLoadingFragment.kt
@@ -0,0 +1,45 @@
+package com.ivyclub.contact.ui.onboard.contact.dialog
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.DialogFragment
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentDialogGetContactsLoadingBinding
+
+class DialogGetContactsLoadingFragment : DialogFragment() {
+ private lateinit var binding: FragmentDialogGetContactsLoadingBinding
+ private var isShowing = false
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ isCancelable = false
+ isShowing = true
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ binding = DataBindingUtil.inflate(
+ inflater,
+ R.layout.fragment_dialog_get_contacts_loading,
+ container,
+ false
+ )
+ return binding.root
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ isShowing = false
+ }
+
+ fun getIsShowing() = isShowing
+
+ companion object {
+ const val TAG = "DIALOG_GET_CONTACTS_LOADING_FRAGMENT"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/notification/NotificationTimeFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/notification/NotificationTimeFragment.kt
new file mode 100644
index 00000000..6ff565cc
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/notification/NotificationTimeFragment.kt
@@ -0,0 +1,58 @@
+package com.ivyclub.contact.ui.onboard.notification
+
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentNotificationTimeBinding
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.SkipDialog
+import dagger.hilt.android.AndroidEntryPoint
+import kotlin.math.roundToInt
+
+@AndroidEntryPoint
+class NotificationTimeFragment :
+ BaseFragment(R.layout.fragment_notification_time) {
+
+ private val viewModel: NotificationTimeViewModel by viewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initRangeSlider()
+ initButtons()
+ initAppBar()
+ }
+
+ private fun initButtons() {
+ binding.btnNext.setOnClickListener {
+ viewModel.setNotificationOnOff(binding.swOnOff.isChecked)//binding.swOnOff.isChecked로 판단하기
+ viewModel.setTime(binding.rsTimeRange.values)
+ findNavController().navigate(R.id.action_notificationTimeFragment_to_addContactFragment)
+ }
+ }
+
+ private fun initRangeSlider() {
+ with(binding.rsTimeRange) {
+ values = listOf(8f, 22f)
+ setLabelFormatter { value: Float ->
+ return@setLabelFormatter getString(R.string.notification_time_fragment_hour, value.roundToInt().toString())
+ }
+ }
+ }
+
+ private fun initAppBar() {
+ binding.tbNotificationTime.inflateMenu(R.menu.menu_on_boarding)
+ binding.tbNotificationTime.setOnMenuItemClickListener {
+ if (it.itemId == R.id.skip) {
+ SkipDialog(ok, context).showDialog()
+ }
+ true
+ }
+ }
+
+ private val ok = DialogInterface.OnClickListener { _, _ ->
+ activity?.finish()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/onboard/notification/NotificationTimeViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/onboard/notification/NotificationTimeViewModel.kt
new file mode 100644
index 00000000..7e9ca2be
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/onboard/notification/NotificationTimeViewModel.kt
@@ -0,0 +1,26 @@
+package com.ivyclub.contact.ui.onboard.notification
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class NotificationTimeViewModel @Inject constructor(
+ private val repository: ContactRepository
+) : ViewModel() {
+
+ fun setTime(times: List) {
+ viewModelScope.launch {
+ repository.setNotificationTimeRange(times[0].toInt(), times[1].toInt())
+ }
+ }
+
+ fun setNotificationOnOff(state: Boolean) {
+ viewModelScope.launch {
+ repository.setNotificationOnOff(state)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/password/PasswordActivity.kt b/app/src/main/java/com/ivyclub/contact/ui/password/PasswordActivity.kt
new file mode 100644
index 00000000..0f0ce61d
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/password/PasswordActivity.kt
@@ -0,0 +1,14 @@
+package com.ivyclub.contact.ui.password
+
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ActivityPasswordBinding
+import com.ivyclub.contact.util.BaseActivity
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class PasswordActivity : BaseActivity(R.layout.activity_password) {
+ override fun onBackPressed() {
+ super.onBackPressed()
+ finishAffinity()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/password/PasswordFragment.kt b/app/src/main/java/com/ivyclub/contact/ui/password/PasswordFragment.kt
new file mode 100644
index 00000000..e78d4225
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/password/PasswordFragment.kt
@@ -0,0 +1,288 @@
+package com.ivyclub.contact.ui.password
+
+import android.app.Activity.RESULT_OK
+import android.content.Context
+import android.content.Context.VIBRATOR_SERVICE
+import android.content.Intent
+import android.os.*
+import android.view.View
+import androidx.activity.addCallback
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import com.google.android.material.snackbar.Snackbar
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.FragmentPasswordBinding
+import com.ivyclub.contact.service.password_timer.PasswordTimerWorker
+import com.ivyclub.contact.ui.main.MainActivity
+import com.ivyclub.contact.util.BaseFragment
+import com.ivyclub.contact.util.PasswordViewType
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class PasswordFragment :
+ BaseFragment(R.layout.fragment_password) {
+
+ private val viewModel: PasswordViewModel by viewModels()
+ private val args: PasswordFragmentArgs by navArgs()
+ private val passwordEditTextList by lazy {
+ with(binding) {
+ listOf(etPassword1, etPassword2, etPassword3, etPassword4)
+ }
+ }
+ private val numberButtonList by lazy {
+ with(binding) {
+ listOf(btn0, btn1, btn2, btn3, btn4, btn5, btn6, btn7, btn8, btn9)
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.viewModel = viewModel
+ checkFingerPrintState()
+ initPasswordViewType()
+ initNumberClickListener()
+ initCancelButtonClickListener()
+ initBackPressedListener()
+ observeMoveFragment()
+ observeFocusedEditTextIndex()
+ observeShowSnackBar()
+ blockKeyboard()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (args.passwordViewType == PasswordViewType.APP_CONFIRM_PASSWORD || args.passwordViewType == PasswordViewType.SECURITY_CONFIRM_PASSWORD) {
+ viewModel.initTryCountState()
+ observePasswordTimer()
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ if (args.passwordViewType == PasswordViewType.APP_CONFIRM_PASSWORD || args.passwordViewType == PasswordViewType.SECURITY_CONFIRM_PASSWORD) {
+ viewModel.stopObservePasswordTimer()
+ }
+ }
+
+ private fun checkFingerPrintState() {
+ if (args.passwordViewType == PasswordViewType.APP_CONFIRM_PASSWORD || args.passwordViewType == PasswordViewType.SECURITY_CONFIRM_PASSWORD) {
+ viewModel.checkFingerPrintState()
+ viewModel.fingerPrint.observe(viewLifecycleOwner) {
+ val prompt = createBiometricPrompt()
+ val promptInfo = createBiometricPromptInfo()
+ prompt.authenticate(promptInfo)
+ }
+ }
+ }
+
+ private fun initBackPressedListener() {
+ if (activity == null) return
+ requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
+ when (args.passwordViewType) {
+ PasswordViewType.SECURITY_CONFIRM_PASSWORD -> findNavController().popBackStack(
+ R.id.settingsFragment,
+ false
+ )
+ else -> findNavController().popBackStack()
+ }
+ }
+ }
+
+ private fun initPasswordViewType() {
+ when (args.passwordViewType) {
+ PasswordViewType.SET_PASSWORD -> {
+ viewModel.initPasswordViewType(args.passwordViewType)
+ viewModel.moveToReconfirmPassword.observe(viewLifecycleOwner) { password ->
+ findNavController().navigate(
+ PasswordFragmentDirections.actionSetPasswordFragmentSelf(
+ PasswordViewType.RECONFIRM_PASSWORD,
+ password
+ )
+ )
+ }
+ }
+ PasswordViewType.RECONFIRM_PASSWORD -> {
+ viewModel.initPasswordViewType(args.passwordViewType, args.password)
+ binding.tvPassword.text = getString(R.string.password_reconfirm_message)
+
+ }
+ PasswordViewType.APP_CONFIRM_PASSWORD -> {
+ viewModel.initPasswordViewType(args.passwordViewType)
+ viewModel.finishConfirmPassword.observe(viewLifecycleOwner) {
+ val intent = Intent(context, MainActivity::class.java)
+ activity?.setResult(RESULT_OK, intent)
+ activity?.finish()
+ }
+ viewModel.retry.observe(viewLifecycleOwner) {
+ binding.tvPassword.text = getString(R.string.password_retry_message)
+ vibrate()
+ }
+ observeTimer()
+ observeTryCount()
+ observeNumberButtonClickable()
+ }
+ PasswordViewType.SECURITY_CONFIRM_PASSWORD -> {
+ viewModel.initPasswordViewType(args.passwordViewType, args.password)
+ viewModel.retry.observe(viewLifecycleOwner) {
+ binding.tvPassword.text = getString(R.string.password_retry_message)
+ vibrate()
+ }
+ observeTimer()
+ observeTryCount()
+ observeNumberButtonClickable()
+ }
+ }
+ }
+
+ private fun observePasswordTimer() {
+ viewModel.observePasswordTimer(activationPasswordButton, updateTimer)
+ }
+
+ private fun observeTryCount() {
+ viewModel.tryCount.observe(viewLifecycleOwner) { tryCount ->
+ if (tryCount == 10) {
+ binding.tvPassword.text = getString(R.string.password_wrong_ten_times)
+ numberButtonList.forEach {
+ it.isClickable = false
+ }
+ updateTimer()
+ viewModel.timer.observe(viewLifecycleOwner) {
+ binding.tvTryAfter.isVisible = true
+ binding.tvTryAfter.text = String.format(getString(R.string.format_password_try_after), it/60 + 1)
+ }
+ observePasswordTimer()
+ } else {
+ activationPasswordButton()
+ }
+ }
+ }
+
+ private fun observeNumberButtonClickable() {
+ viewModel.isNumberButtonClickable.observe(viewLifecycleOwner) { isClickable ->
+ if (!isClickable) {
+ numberButtonList.forEach {
+ it.isClickable = false
+ }
+ }
+ }
+ }
+
+ private val updateTimer = {
+ viewModel.getTimerInfo()
+ }
+
+ private val activationPasswordButton = {
+ binding.tvPassword.text = getString(R.string.password_input_password)
+ binding.tvTryAfter.isVisible = false
+ numberButtonList.forEach {
+ it.isClickable = true
+ }
+ }
+
+ private fun observeTimer() {
+ val workName = "PasswordTimer"
+
+ viewModel.setTimer.observe(viewLifecycleOwner) {
+ val workRequest = OneTimeWorkRequestBuilder().build()
+ context?.let { context ->
+ WorkManager.getInstance(context)
+ .enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
+ }
+ }
+
+ viewModel.stopTimer.observe(viewLifecycleOwner) {
+ context?.let { context -> WorkManager.getInstance(context).cancelUniqueWork(workName) }
+ }
+ }
+
+ private fun vibrate() {
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager =
+ activity?.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
+ vibratorManager.defaultVibrator;
+ } else {
+ activity?.getSystemService(VIBRATOR_SERVICE) as Vibrator
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ vibrator.vibrate(VibrationEffect.createOneShot(100, VibrationEffect.DEFAULT_AMPLITUDE))
+ } else {
+ vibrator.vibrate(100)
+ }
+ }
+
+ private fun initNumberClickListener() {
+ numberButtonList.forEachIndexed { number, button ->
+ button.setOnClickListener {
+ viewModel.moveFocusFront(number.toString())
+ }
+ }
+ }
+
+ private fun initCancelButtonClickListener() {
+ binding.btnCancel.setOnClickListener {
+ viewModel.moveFocusBack()
+ }
+ }
+
+ private fun observeFocusedEditTextIndex() {
+ viewModel.focusedEditTextIndex.observe(viewLifecycleOwner) {
+ passwordEditTextList[it - 1].requestFocus()
+ }
+ }
+
+ private fun observeMoveFragment() {
+ viewModel.moveToSetPassword.observe(viewLifecycleOwner) {
+ findNavController().navigate(
+ PasswordFragmentDirections.actionSetPasswordFragmentSelf(
+ PasswordViewType.SET_PASSWORD
+ )
+ )
+ }
+ viewModel.moveToPreviousFragment.observe(viewLifecycleOwner) {
+ findNavController().popBackStack()
+ }
+ }
+
+ private fun observeShowSnackBar() {
+ viewModel.showSnackBar.observe(viewLifecycleOwner) { id ->
+ Snackbar.make(binding.root, getString(id), Snackbar.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun blockKeyboard() {
+ passwordEditTextList.forEach {
+ it.setTextIsSelectable(true)
+ it.showSoftInputOnFocus = false
+ it.isFocusableInTouchMode = false
+ }
+ }
+
+ private fun createBiometricPromptInfo(): BiometricPrompt.PromptInfo {
+ return BiometricPrompt.PromptInfo.Builder()
+ .setTitle(getString(R.string.biometric_prompt_title))
+ .setDescription(getString(R.string.biometric_prompt_description))
+ .setNegativeButtonText(getString(R.string.biometric_prompt_cancel))
+ .build()
+ }
+
+ private fun createBiometricPrompt(): BiometricPrompt {
+ val executor = ContextCompat.getMainExecutor(requireContext())
+ val authenticationCallback = getAuthenticationCallback()
+ return BiometricPrompt(this, executor, authenticationCallback)
+ }
+
+ private fun getAuthenticationCallback() = object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ viewModel.succeedFingerPrintAuth()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/password/PasswordViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/password/PasswordViewModel.kt
new file mode 100644
index 00000000..f0f22904
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/password/PasswordViewModel.kt
@@ -0,0 +1,222 @@
+package com.ivyclub.contact.ui.password
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.ivyclub.contact.R
+import com.ivyclub.contact.util.PasswordViewType
+import com.ivyclub.contact.util.SingleLiveEvent
+import com.ivyclub.data.ContactRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.launch
+import org.mindrot.jbcrypt.BCrypt
+import javax.inject.Inject
+
+@HiltViewModel
+class PasswordViewModel @Inject constructor(private val repository: ContactRepository) :
+ ViewModel() {
+
+ private lateinit var passwordViewType: PasswordViewType
+ lateinit var password: String
+
+ private val _focusedEditTextIndex = MutableLiveData(1)
+ val focusedEditTextIndex: LiveData get() = _focusedEditTextIndex
+
+ private val _moveToReconfirmPassword = SingleLiveEvent()
+ val moveToReconfirmPassword: LiveData get() = _moveToReconfirmPassword
+ private val _moveToPreviousFragment = SingleLiveEvent()
+ val moveToPreviousFragment: LiveData get() = _moveToPreviousFragment
+ private val _moveToSetPassword = SingleLiveEvent()
+ val moveToSetPassword: LiveData get() = _moveToSetPassword
+ private val _finishConfirmPassword = SingleLiveEvent()
+ val finishConfirmPassword: LiveData get() = _finishConfirmPassword
+ private val _showSnackBar = SingleLiveEvent()
+ val showSnackBar: LiveData get() = _showSnackBar
+ private val _retry = SingleLiveEvent()
+ val retry: LiveData get() = _retry
+ private val _tryCount = MutableLiveData()
+ val tryCount: LiveData get() = _tryCount
+ private val _timer = MutableLiveData()
+ val timer: LiveData get() = _timer
+ private val _setTimer = SingleLiveEvent()
+ val setTimer: LiveData get() = _setTimer
+ private val _stopTimer = SingleLiveEvent()
+ val stopTimer: LiveData get() = _stopTimer
+ private val _isNumberButtonClickable = SingleLiveEvent()
+ val isNumberButtonClickable: LiveData get() = _isNumberButtonClickable
+
+
+ private val _fingerPrint = SingleLiveEvent()
+ val fingerPrint: LiveData get() = _fingerPrint
+
+ private val _password1 = MutableLiveData("")
+ val password1: LiveData get() = _password1
+ private val _password2 = MutableLiveData("")
+ val password2: LiveData get() = _password2
+ private val _password3 = MutableLiveData("")
+ val password3: LiveData get() = _password3
+ private val _password4 = MutableLiveData("")
+ val password4: LiveData get() = _password4
+
+ fun initPasswordViewType(type: PasswordViewType, password: String = "") {
+ passwordViewType = type
+ when (passwordViewType) {
+ PasswordViewType.APP_CONFIRM_PASSWORD, PasswordViewType.SECURITY_CONFIRM_PASSWORD -> {
+ viewModelScope.launch {
+ this@PasswordViewModel.password = repository.getPassword()
+ }
+ }
+ else -> {
+ this.password = password
+ }
+ }
+ }
+
+ fun initTryCountState() = viewModelScope.launch {
+ _tryCount.value = repository.getPasswordTryCount()
+ }
+
+ fun observePasswordTimer(activateButton: () -> Unit, updateTimer: () -> Unit) {
+ val tryCount = tryCount.value
+
+ if (tryCount == 10) {
+ viewModelScope.launch {
+ repository.observePasswordTimer(activateButton, updateTimer)
+ }
+ }
+ }
+
+ fun stopObservePasswordTimer() {
+ val tryCount = tryCount.value
+
+ if (tryCount == 10) {
+ repository.stopObservePasswordTimer()
+ }
+ }
+
+ private fun updatePasswordInput(number: String) {
+ when (focusedEditTextIndex.value) {
+ 1 -> {
+ _password1.value = number
+ }
+ 2 -> {
+ _password2.value = number
+ }
+ 3 -> {
+ _password3.value = number
+ }
+ 4 -> {
+ _password4.value = number
+ }
+ }
+ }
+
+ fun moveFocusBack() {
+ if (focusedEditTextIndex.value != 1) {
+ _focusedEditTextIndex.value = _focusedEditTextIndex.value?.minus(1)
+ updatePasswordInput("")
+ }
+ }
+
+ fun moveFocusFront(number: String) {
+ updatePasswordInput(number)
+ if (focusedEditTextIndex.value != 4) {
+ _focusedEditTextIndex.value = _focusedEditTextIndex.value?.plus(1)
+ } else {
+ nextStep()
+ }
+ }
+
+ private fun nextStep() {
+ val inputPassword =
+ "${password1.value}${password2.value}${password3.value}${password4.value}"
+
+ when (passwordViewType) {
+ PasswordViewType.SET_PASSWORD -> {
+ _moveToReconfirmPassword.value = inputPassword
+ }
+ PasswordViewType.RECONFIRM_PASSWORD -> {
+ if (password == inputPassword) {
+ savePassword(password)
+ _moveToPreviousFragment.call()
+ _showSnackBar.value = R.string.password_set_success
+ } else {
+ _moveToSetPassword.call()
+ _showSnackBar.value = R.string.password_reconfirm_fail
+ }
+ }
+ PasswordViewType.APP_CONFIRM_PASSWORD, PasswordViewType.SECURITY_CONFIRM_PASSWORD -> {
+ if (BCrypt.checkpw(inputPassword, password)) {
+ _isNumberButtonClickable.value = false
+ if (passwordViewType == PasswordViewType.APP_CONFIRM_PASSWORD) {
+ _finishConfirmPassword.call()
+ } else {
+ _moveToPreviousFragment.call()
+ }
+ _stopTimer.call()
+ viewModelScope.launch {
+ repository.savePasswordTryCount(0)
+ repository.savePasswordTimer(-1)
+ }
+ } else {
+ if (_tryCount.value != null) {
+ if (_tryCount.value == 10) {
+ _tryCount.value = 1
+ } else {
+ _tryCount.value = _tryCount.value!! + 1
+ }
+ viewModelScope.launch {
+ repository.savePasswordTryCount(_tryCount.value!!)
+ }
+ if (_tryCount.value != 10) {
+ _setTimer.call()
+ _retry.call()
+ }
+ }
+ reset()
+ }
+ }
+ }
+ }
+
+ private fun reset() {
+ _password1.value = ""
+ _password2.value = ""
+ _password3.value = ""
+ _password4.value = ""
+ _focusedEditTextIndex.value = 1
+ }
+
+ private fun savePassword(password: String) = viewModelScope.launch {
+ repository.savePassword(BCrypt.hashpw(password, BCrypt.gensalt(10)))
+ }
+
+ fun getTimerInfo() {
+ viewModelScope.launch {
+ var savedTimer = repository.getPasswordTimer()
+ if (savedTimer == -1) {
+ savedTimer = 300 - 1
+ _setTimer.call()
+ }
+ _timer.value = savedTimer
+ }
+ }
+
+ fun checkFingerPrintState() {
+ viewModelScope.launch {
+ val fingerPrint = repository.getFingerPrintState()
+ if (fingerPrint) {
+ _fingerPrint.call()
+ }
+ }
+ }
+
+ fun succeedFingerPrintAuth() {
+ if (passwordViewType == PasswordViewType.APP_CONFIRM_PASSWORD) {
+ _finishConfirmPassword.call()
+ } else if (passwordViewType == PasswordViewType.SECURITY_CONFIRM_PASSWORD) {
+ _moveToPreviousFragment.call()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListAdapter.kt b/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListAdapter.kt
new file mode 100644
index 00000000..4f680ec7
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListAdapter.kt
@@ -0,0 +1,141 @@
+package com.ivyclub.contact.ui.plan_list
+
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ItemPlanListBinding
+import com.ivyclub.contact.databinding.ItemPlanListHeaderBinding
+import com.ivyclub.contact.util.DAY_IN_MILLIS
+import com.ivyclub.contact.util.StringManager.getDateFormatBy
+import com.ivyclub.contact.util.StringManager.getMonthFormatBy
+import com.ivyclub.contact.util.binding
+import com.ivyclub.contact.util.setFriendChips
+import kotlin.math.abs
+
+class PlanListAdapter(
+ val onItemClick: (Long) -> (Unit)
+) : ListAdapter(diffUtil) {
+
+ private lateinit var scrollToRecentDateCallback: () -> (Unit)
+ private lateinit var refreshVisibleListCallback: () -> (Unit)
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+ PlanViewHolder(
+ parent.binding(R.layout.item_plan_list)
+ )
+
+ override fun onBindViewHolder(holder: PlanViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ super.onAttachedToRecyclerView(recyclerView)
+
+ scrollToRecentDateCallback = {
+ val currentDay = System.currentTimeMillis() / DAY_IN_MILLIS
+ var minGap = currentDay
+ var minIdx = 0
+ currentList.map { it.dayCount }
+ .forEachIndexed { index, day ->
+ val gap = abs(currentDay - day)
+ if (gap < minGap ||
+ (gap == minGap && currentDay < day)
+ ) {
+ minGap = gap
+ minIdx = index
+ }
+ }
+
+ recyclerView.scrollToPosition(minIdx)
+ }
+
+ refreshVisibleListCallback = {
+ val layoutManager = recyclerView.layoutManager as LinearLayoutManager
+ val firstPosition = layoutManager.findFirstVisibleItemPosition() - 1
+ val itemCount = layoutManager.findLastVisibleItemPosition() - firstPosition
+ notifyItemRangeChanged(maxOf(firstPosition, 0), itemCount + 2)
+ }
+ }
+
+ override fun onCurrentListChanged(
+ previousList: MutableList,
+ currentList: MutableList
+ ) {
+ super.onCurrentListChanged(previousList, currentList)
+ scrollToRecentDateCallback.invoke()
+ refreshVisibleListCallback.invoke()
+ }
+
+ fun isHeader(position: Int): Boolean {
+ if (position == 0)
+ return true
+
+ val currentItem = getItem(position)
+ val lastItem = getItem(position - 1)
+
+ return currentItem.planMonth != lastItem.planMonth || currentItem.planYear != lastItem.planYear
+ }
+
+ fun getHeaderView(rv: RecyclerView, position: Int): View {
+ val binding = rv.binding(R.layout.item_plan_list_header)
+ binding.tvPlanMonth.text = getMonthFormatBy(getItem(position).planMonth)
+ binding.viewModel = getItem(position)
+ binding.executePendingBindings()
+ return binding.root
+ }
+
+ inner class PlanViewHolder(
+ private val binding: ItemPlanListBinding
+ ) : RecyclerView.ViewHolder(binding.root) {
+
+ private var planId: Long? = null
+
+ init {
+ itemView.setOnClickListener {
+ planId?.let { id ->
+ onItemClick(id)
+ }
+ }
+ }
+
+ fun bind(itemViewModel: PlanListItemViewModel) {
+ planId = itemViewModel.id
+
+ with(binding) {
+ // todo 앱 사용 중간에 언어를 바꾸는 것 감지하고 대응
+ tvPlanDate.text =
+ getDateFormatBy(
+ itemViewModel.planDayOfMonth.toString(),
+ itemViewModel.planDayOfWeek.translated.invoke()
+ )
+ tvPlanMonth.text = getMonthFormatBy(itemViewModel.planMonth)
+ viewModel = itemViewModel
+ cgPlanFriends.setFriendChips(itemViewModel.friends, 3) {
+ itemView.performClick()
+ }
+ llMonthYear.visibility =
+ if (isHeader(adapterPosition)) View.VISIBLE else View.INVISIBLE
+ }
+ }
+ }
+
+ companion object {
+ private val diffUtil = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(
+ oldItem: PlanListItemViewModel,
+ newItem: PlanListItemViewModel
+ ) =
+ oldItem.id == newItem.id
+
+ override fun areContentsTheSame(
+ oldItem: PlanListItemViewModel,
+ newItem: PlanListItemViewModel
+ ) =
+ oldItem == newItem
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListHeaderItemDecoration.kt b/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListHeaderItemDecoration.kt
new file mode 100644
index 00000000..15ddb6f6
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListHeaderItemDecoration.kt
@@ -0,0 +1,101 @@
+package com.ivyclub.contact.ui.plan_list
+
+import android.graphics.Canvas
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+
+class PlanListHeaderItemDecoration(private val sectionCallback: SectionCallback) :
+ RecyclerView.ItemDecoration() {
+
+ // onDrawOver 함수로 RecyclerView 위에 새로운 뷰를 그려준다.
+ override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
+ super.onDrawOver(c, parent, state)
+ Log.d("DRAW", "onDrawOver 호출")
+ val topChild = parent.getChildAt(0) ?: return
+
+ val topChildPosition = parent.getChildAdapterPosition(topChild)
+ if (topChildPosition == RecyclerView.NO_POSITION) return
+
+ // 헤더뷰
+ val currentHeader: View = sectionCallback.getHeaderView(parent, topChildPosition) ?: return
+
+ // 헤더뷰 크기 지정
+ fixHeaderSize(parent, currentHeader, topChild.measuredHeight)
+
+ val contactPoint = currentHeader.bottom // 현재 헤더의 bottom이 부모의 top에서 얼마나 떨어졌는지
+ val childInContact: View = getChildInContact(parent, contactPoint) ?: return
+
+ val childAdapterPosition = parent.getChildAdapterPosition(childInContact)
+ if (childAdapterPosition == RecyclerView.NO_POSITION) return
+
+ when {
+ sectionCallback.isHeader(childAdapterPosition) -> moveHeader(c, currentHeader, childInContact)
+ else -> drawHeader(c, currentHeader)
+ }
+
+ }
+
+ private fun moveHeader(c: Canvas, currentHeader: View, childInContact: View) {
+ c.save()
+ c.translate(0f, childInContact.top - currentHeader.height.toFloat())
+ currentHeader.draw(c)
+ c.restore()
+ }
+
+ private fun drawHeader(c: Canvas, currentHeader: View) {
+ c.save()
+ c.translate(0f, 0f)
+ currentHeader.draw(c)
+ c.restore()
+ }
+
+ private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? {
+ var childInContact: View? = null
+ for (i in 0 until parent.childCount) {
+ val child = parent.getChildAt(i)
+ if (child.bottom > contactPoint) {
+ if (child.top <= contactPoint) {
+ childInContact = child
+ break
+ }
+ }
+ }
+ return childInContact
+ }
+
+ private fun fixHeaderSize(parent: RecyclerView, currentHeader: View, measuredHeight: Int) {
+ val widthSpec = View.MeasureSpec.makeMeasureSpec(
+ parent.width,
+ View.MeasureSpec.EXACTLY
+ )
+
+ val heightSpec = View.MeasureSpec.makeMeasureSpec(
+ parent.height,
+ View.MeasureSpec.EXACTLY
+ )
+
+ val childWidth: Int = ViewGroup.getChildMeasureSpec(
+ widthSpec,
+ parent.paddingStart + parent.paddingEnd,
+ currentHeader.layoutParams.width
+ )
+
+ val childHeight: Int = ViewGroup.getChildMeasureSpec(
+ heightSpec,
+ 0,
+ measuredHeight
+ )
+
+ currentHeader.measure(childWidth, childHeight)
+ currentHeader.layout(0, 0, currentHeader.measuredWidth, currentHeader.measuredHeight)
+ }
+
+ interface SectionCallback {
+ // header가 고정되어있는지, 움직여야하는지 판단할 함수
+ fun isHeader(position: Int): Boolean
+ // 헤더 뷰를 반환하는 함수
+ fun getHeaderView(list: RecyclerView, position: Int): View?
+ }
+}
diff --git a/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListItemViewModel.kt b/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListItemViewModel.kt
new file mode 100644
index 00000000..a75f965c
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/plan_list/PlanListItemViewModel.kt
@@ -0,0 +1,19 @@
+package com.ivyclub.contact.ui.plan_list
+
+import com.ivyclub.contact.util.*
+import com.ivyclub.data.model.SimplePlanData
+import java.sql.Date
+
+data class PlanListItemViewModel(
+ private val planData: SimplePlanData,
+ val friends: List
+) {
+ val id = planData.id
+ private val date: Date = planData.date
+ val dayCount = date.time / DAY_IN_MILLIS
+ val planMonth = date.getExactMonth()
+ val planYear = date.getExactYear()
+ val planDayOfMonth = date.getDayOfMonth()
+ val planDayOfWeek = date.getDayOfWeek()
+ val title: String = planData.title
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/ui/splash/SplashActivity.kt b/app/src/main/java/com/ivyclub/contact/ui/splash/SplashActivity.kt
new file mode 100644
index 00000000..7fcccdb3
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/ui/splash/SplashActivity.kt
@@ -0,0 +1,36 @@
+package com.ivyclub.contact.ui.splash
+
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import com.ivyclub.contact.R
+import com.ivyclub.contact.databinding.ActivitySplashBinding
+import com.ivyclub.contact.ui.main.MainActivity
+import com.ivyclub.contact.util.BaseActivity
+
+class SplashActivity : BaseActivity(R.layout.activity_splash) {
+
+ private val splashHandler = Handler(Looper.getMainLooper())
+
+ private val moveToMainActivity = Runnable {
+ startActivity(Intent(this, MainActivity::class.java))
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_splash)
+
+ splashHandler.postDelayed(moveToMainActivity, SPLASH_TIME)
+ }
+
+ override fun onBackPressed() {
+ super.onBackPressed()
+ splashHandler.removeCallbacks(moveToMainActivity)
+ }
+
+ companion object {
+ private const val SPLASH_TIME = 2000L
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/util/BaseActivity.kt b/app/src/main/java/com/ivyclub/contact/util/BaseActivity.kt
new file mode 100644
index 00000000..4156e919
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/util/BaseActivity.kt
@@ -0,0 +1,17 @@
+package com.ivyclub.contact.util
+
+import android.os.Bundle
+import androidx.annotation.LayoutRes
+import androidx.appcompat.app.AppCompatActivity
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ViewDataBinding
+
+abstract class BaseActivity(@LayoutRes val layoutRes: Int) :
+ AppCompatActivity() {
+ protected lateinit var binding: T
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = DataBindingUtil.setContentView(this, layoutRes)
+ binding.lifecycleOwner = this
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/util/BaseFragment.kt b/app/src/main/java/com/ivyclub/contact/util/BaseFragment.kt
new file mode 100644
index 00000000..739b5c1c
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/util/BaseFragment.kt
@@ -0,0 +1,36 @@
+package com.ivyclub.contact.util
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.LayoutRes
+import androidx.core.view.children
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ViewDataBinding
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.RecyclerView
+
+abstract class BaseFragment(@LayoutRes val layoutRes: Int) : Fragment() {
+
+ private var _binding: T? = null
+ protected val binding get() = _binding ?: error("binding이 초기화되지 않았습니다.")
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = DataBindingUtil.inflate(inflater, layoutRes, container, false)
+ binding.lifecycleOwner = viewLifecycleOwner
+ return binding.root
+ }
+
+ override fun onDestroyView() {
+ (binding.root as ViewGroup).children
+ .filter { it is RecyclerView }
+ .forEach { (it as RecyclerView).adapter = null }
+ _binding = null
+ super.onDestroyView()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/util/BindingAdapter.kt b/app/src/main/java/com/ivyclub/contact/util/BindingAdapter.kt
new file mode 100644
index 00000000..d9d98525
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/util/BindingAdapter.kt
@@ -0,0 +1,42 @@
+package com.ivyclub.contact.util
+
+import android.widget.ImageView
+import androidx.databinding.BindingAdapter
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.engine.DiskCacheStrategy
+import com.bumptech.glide.signature.ObjectKey
+import com.ivyclub.contact.R
+import java.io.File
+
+@BindingAdapter("bindImage")
+fun bindImage(
+ imageView: ImageView,
+ profileId: Long
+) {
+ val imageString = "${imageView.context.cacheDir}/$profileId.jpg"
+ val targetFile = File(imageString)
+ if (targetFile.exists()) {
+ Glide.with(imageView)
+ .load(imageString)
+ .signature(ObjectKey(targetFile.lastModified()))
+ .into(imageView)
+ } else {
+ Glide.with(imageView)
+ .load(R.drawable.photo)
+ .into(imageView)
+ }
+ imageView.clipToOutline = true
+}
+
+@BindingAdapter("bindPlanImage")
+fun bindPlanImage(
+ imageView: ImageView,
+ imageString: String
+) {
+ Glide.with(imageView)
+ .load(imageString)
+ .diskCacheStrategy(DiskCacheStrategy.NONE) // 디스크 캐시 저장 끄기
+ .skipMemoryCache(true) // 메모리 캐시 저장 끄기
+ .into(imageView)
+ imageView.clipToOutline = true
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/util/BindingExtension.kt b/app/src/main/java/com/ivyclub/contact/util/BindingExtension.kt
new file mode 100644
index 00000000..8f9161f6
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/util/BindingExtension.kt
@@ -0,0 +1,12 @@
+package com.ivyclub.contact.util
+
+import androidx.databinding.ViewDataBinding
+import com.google.android.material.snackbar.Snackbar
+
+fun ViewDataBinding.makeShortSnackBar(content: String) {
+ Snackbar.make(
+ this.root,
+ content,
+ Snackbar.LENGTH_SHORT
+ ).show()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ivyclub/contact/util/ContactListManager.kt b/app/src/main/java/com/ivyclub/contact/util/ContactListManager.kt
new file mode 100644
index 00000000..061901dc
--- /dev/null
+++ b/app/src/main/java/com/ivyclub/contact/util/ContactListManager.kt
@@ -0,0 +1,35 @@
+package com.ivyclub.contact.util
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.provider.ContactsContract
+import com.ivyclub.contact.model.PhoneContactData
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+class ContactListManager @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ @SuppressLint("Range")
+ fun getContact(): MutableList