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조 아이비클럽 저장소입니다. +

컨택

+ +

+ API + API +

+ +

+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 { + val contactList: MutableList = mutableListOf() + val contacts = context.applicationContext?.contentResolver?.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + null, + null, + null, + null + ) ?: return mutableListOf() + while (contacts.moveToNext()) { + val name = + contacts.getString(contacts.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)) + val number = + contacts.getString(contacts.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)) + val obj = PhoneContactData(name, number) + contactList.add(obj) + } + contacts.close() + return contactList.toSet().toMutableList() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/DateUtil.kt b/app/src/main/java/com/ivyclub/contact/util/DateUtil.kt new file mode 100644 index 00000000..12eb87bd --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/DateUtil.kt @@ -0,0 +1,42 @@ +package com.ivyclub.contact.util + +import java.sql.Date +import java.util.* + +const val MINUTE_IN_MILLIS = (1000 * 60).toLong() +const val HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60 +const val DAY_IN_MILLIS = HOUR_IN_MILLIS * 24 + +private val calendar: Calendar by lazy { Calendar.getInstance() } + +fun Date.getExactYear(): Int = getCalendar(this).get(Calendar.YEAR) + +fun Date.getExactMonth(): Int = getCalendar(this).get(Calendar.MONTH) + 1 + +fun Date.getDayOfMonth(): Int = getCalendar(this).get(Calendar.DAY_OF_MONTH) + +fun Date.getDayOfWeek(): DayOfWeek { + return DayOfWeek.values()[getCalendar(this).get(Calendar.DAY_OF_WEEK) - 1] +} + +fun Date.getHour(): Int = getCalendar(this).get(Calendar.HOUR_OF_DAY) + +fun Date.getMinute(): Int = getCalendar(this).get(Calendar.MINUTE) + +fun Date.getNewDate(year: Int, month: Int, dayOfMonth: Int): Date { + getCalendar(this).set(year, month, dayOfMonth) + return Date(calendar.time.time) +} + +fun Date.getNewTime(hour: Int, minute: Int): Date { + with(getCalendar(this)) { + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minute) + return Date(calendar.time.time) + } +} + +private fun getCalendar(date: Date): Calendar { + calendar.time = date + return calendar +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/EnumClasses.kt b/app/src/main/java/com/ivyclub/contact/util/EnumClasses.kt new file mode 100644 index 00000000..925912cb --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/EnumClasses.kt @@ -0,0 +1,51 @@ +package com.ivyclub.contact.util + +import androidx.annotation.Keep + +enum class DayOfWeek(val value: Int) : Translatable { + SUN(1) { + override val translated = { StringManager.getString("일") } + }, + MON(2) { + override val translated = { StringManager.getString("월") } + }, + TUE(3) { + override val translated = { StringManager.getString("화") } + }, + WED(4) { + override val translated = { StringManager.getString("수") } + }, + THU(5) { + override val translated = { StringManager.getString("목") } + }, + FRI(6) { + override val translated = { StringManager.getString("금") } + }, + SAT(0) { + override val translated = { StringManager.getString("토") } + } +} + +interface Translatable { + val translated: () -> String +} + +enum class FriendListViewType { + GROUP_NAME, FRIEND, GROUP_DIVIDER +} + +@Keep +enum class PasswordViewType { + SET_PASSWORD, RECONFIRM_PASSWORD, APP_CONFIRM_PASSWORD, SECURITY_CONFIRM_PASSWORD +} + +enum class NotificationType(val value: String) { + PLAN("PLAN"), MORNING("MORNING"), NIGHT("NIGHT") +} + +enum class Month { + ; + enum class EngMonth { + Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/FlowExtension.kt b/app/src/main/java/com/ivyclub/contact/util/FlowExtension.kt new file mode 100644 index 00000000..15c1cc52 --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/FlowExtension.kt @@ -0,0 +1,22 @@ +package com.ivyclub.contact.util + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow + +fun Flow.throttleFist(windowDuration: Long): Flow = flow { + var windowStartTime = System.currentTimeMillis() + var emitted = false + collect { value -> + val currentTime = System.currentTimeMillis() + val diff = currentTime - windowStartTime + if (diff >= windowDuration) { + windowStartTime += diff / windowDuration * windowDuration + emitted = false + } + if (!emitted) { + emit(value) + emitted = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/PixelRatio.kt b/app/src/main/java/com/ivyclub/contact/util/PixelRatio.kt new file mode 100644 index 00000000..8b7cd3bb --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/PixelRatio.kt @@ -0,0 +1,21 @@ +package com.ivyclub.contact.util + +import android.app.Application +import androidx.annotation.Px +import com.ivyclub.contact.MainApplication +import kotlin.math.roundToInt + +class PixelRatio(private val app: Application) { + private val displayMetrics + get() = app.resources.displayMetrics + + @Px + fun dpToPixel(dp: Int) = (dp * displayMetrics.density).roundToInt() + fun dpToPixelFloat(dp: Float) = (dp * displayMetrics.density).roundToInt() +} + +val Number.dpToPixel: Int + get() = MainApplication.pixelRatio.dpToPixel(this.toInt()) + +val Number.dpToPixelFloat: Float + get() = MainApplication.pixelRatio.dpToPixelFloat(this.toFloat()).toFloat() \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/SealedClasses.kt b/app/src/main/java/com/ivyclub/contact/util/SealedClasses.kt new file mode 100644 index 00000000..6bb3dd1b --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/SealedClasses.kt @@ -0,0 +1,9 @@ +package com.ivyclub.contact.util + +sealed class ContactSavingUiState { + object Loading : ContactSavingUiState() + object LoadingDone : ContactSavingUiState() + object Dialog : ContactSavingUiState() + object DialogDone : ContactSavingUiState() + object Empty : ContactSavingUiState() +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/SingleLiveEvent.kt b/app/src/main/java/com/ivyclub/contact/util/SingleLiveEvent.kt new file mode 100644 index 00000000..0e47cc87 --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/SingleLiveEvent.kt @@ -0,0 +1,35 @@ +package com.ivyclub.contact.util + +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import java.util.concurrent.atomic.AtomicBoolean + +class SingleLiveEvent : MutableLiveData() { + + private val isPending = AtomicBoolean(false) + + @MainThread + override fun setValue(value: T?) { + isPending.set(true) + super.setValue(value) + } + + override fun postValue(value: T?) { + isPending.set(true) + super.postValue(value) + } + + override fun observe(owner: LifecycleOwner, observer: Observer) { + super.observe(owner, { + if (isPending.compareAndSet(true, false)) { + observer.onChanged(it) + } + }) + } + + fun call() { + postValue(null) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/SkipDialog.kt b/app/src/main/java/com/ivyclub/contact/util/SkipDialog.kt new file mode 100644 index 00000000..f3ae7ab1 --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/SkipDialog.kt @@ -0,0 +1,26 @@ +package com.ivyclub.contact.util + +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import com.ivyclub.contact.R + +class SkipDialog( + private val ok: DialogInterface.OnClickListener, + private val context: Context? +) { + fun showDialog() { + AlertDialog.Builder(context) + .setTitle(context?.getString(R.string.menu_skip) ?: "Skip") + .setMessage( + context?.getString(R.string.skip_dialog_seriously_end_question) + ?: "Do you want to quit?" + ) + .setPositiveButton(context?.getString(R.string.skip_dialog_yes) ?: "yes", ok) + .setNegativeButton( + context?.getString(R.string.skip_dialog_no) ?: "no" + ) { _, _ -> } + .create() + .show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/StringManager.kt b/app/src/main/java/com/ivyclub/contact/util/StringManager.kt new file mode 100644 index 00000000..bd847760 --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/StringManager.kt @@ -0,0 +1,59 @@ +package com.ivyclub.contact.util + +import android.util.Log +import java.util.* + +object StringManager { + fun getString(targetString: String): String { + Log.d(this::class.simpleName, "getString , $targetString") + when (Locale.getDefault().language) { + "ko" -> { // 한국어일 때 + return targetString + } + "zh" -> { + return when (targetString) { + "친구" -> "好友" + "즐겨찾기" -> "收藏" + "일" -> "周日" + "월" -> "周一" + "화" -> "周二" + "수" -> "周三" + "목" -> "周四" + "금" -> "周五" + "토" -> "周六" + else -> "-" + } + } + else -> { // 영어이거나 지원되지 않는 언어일 때 + return when (targetString) { + "친구" -> "Friend" + "즐겨찾기" -> "Favorite" + "일" -> "Sun" + "월" -> "Mon" + "화" -> "Tue" + "수" -> "Wed" + "목" -> "Thu" + "금" -> "Fri" + "토" -> "Sat" + else -> "-" + } + } + } + } + + fun getDateFormatBy(planDayOfMonth: String, planDayOfWeek: String): String { + return when (Locale.getDefault().language) { + "ko" -> "${planDayOfMonth}일 ${planDayOfWeek}요일" + "zh" -> "${planDayOfMonth}日 $planDayOfWeek" + else -> "$planDayOfMonth $planDayOfWeek" + } + } + + fun getMonthFormatBy(month: Int): String { + return when (Locale.getDefault().language) { // 한글일 때 + "ko" -> "${month}월" + "zh" -> "${month}月" + else -> Month.EngMonth.values()[month - 1].name + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/UriToBitmap.kt b/app/src/main/java/com/ivyclub/contact/util/UriToBitmap.kt new file mode 100644 index 00000000..e37eb96f --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/UriToBitmap.kt @@ -0,0 +1,15 @@ +package com.ivyclub.contact.util + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore + +fun Activity.uriToBitmap(uri: Uri): Bitmap = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(this.contentResolver, uri) +} else { + val source = ImageDecoder.createSource(this.contentResolver, uri) + ImageDecoder.decodeBitmap(source) +} \ No newline at end of file diff --git a/app/src/main/java/com/ivyclub/contact/util/ViewExtension.kt b/app/src/main/java/com/ivyclub/contact/util/ViewExtension.kt new file mode 100644 index 00000000..8b0a9ab2 --- /dev/null +++ b/app/src/main/java/com/ivyclub/contact/util/ViewExtension.kt @@ -0,0 +1,189 @@ +package com.ivyclub.contact.util + +import android.app.AlertDialog +import android.content.Context +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.LinearInterpolator +import android.view.animation.RotateAnimation +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.LayoutRes +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isNotEmpty +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.transition.* +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import com.ivyclub.contact.R +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlin.math.roundToInt + +fun ViewGroup.binding( + @LayoutRes layoutRes: Int, + attachToParent: Boolean = false +): T { + return DataBindingUtil.inflate(LayoutInflater.from(context), layoutRes, this, attachToParent) +} + +fun View.changeVisibilityWithDirection( + direction: Int, + visibility: Int, + animationTime: Long, + callback: () -> Unit = {} +) { + val transition: Transition = TransitionSet().apply { + addTransition(Fade()) + addTransition(Slide(direction)) + duration = animationTime + addTarget(this@changeVisibilityWithDirection) + addListener(object : Transition.TransitionListener { + override fun onTransitionStart(transition: Transition) {} + override fun onTransitionEnd(transition: Transition) { + (this@changeVisibilityWithDirection).visibility = visibility + callback.invoke() + } + + override fun onTransitionCancel(transition: Transition) {} + override fun onTransitionPause(transition: Transition) {} + override fun onTransitionResume(transition: Transition) {} + }) + } + TransitionManager.beginDelayedTransition( + this.parent as ViewGroup, + transition + ) +} + +fun View.setRotateAnimation(from: Float, to: Float) { + val rotate = RotateAnimation( + from, + to, + Animation.RELATIVE_TO_SELF, + 0.5f, + Animation.RELATIVE_TO_SELF, + 0.5f + ).apply { + duration = 200 + interpolator = LinearInterpolator() + fillAfter = true + } + this.startAnimation(rotate) +} + +fun ViewDataBinding.hideKeyboard() { + ViewCompat.getWindowInsetsController(this.root)?.hide(WindowInsetsCompat.Type.ime()) +} + +fun ViewDataBinding.showKeyboard() { + ViewCompat.getWindowInsetsController(this.root)?.show(WindowInsetsCompat.Type.ime()) +} + +fun ChipGroup.setFriendChips( + friendList: List, + chipCount: Int = friendList.size, + onChipClick: ((Int) -> (Unit))? = null +) { + if (isNotEmpty()) removeAllViews() + + friendList.subList(0, chipCount.coerceAtMost(friendList.size)).forEachIndexed { index, name -> + Chip(context).apply { + text = + if (friendList.size > chipCount && index == chipCount - 1) { + String.format( + context.getString(R.string.format_friend_count_etc), + name, + friendList.size - chipCount + ) + } else { + name + } + setChipBackgroundColorResource(R.color.blue_100) + setEnsureMinTouchTargetSize(false) + chipMinHeight = 8f + + onChipClick?.let { onClick -> + setOnClickListener { onClick.invoke(index) } + } + }.also { + addView(it) + } + } +} + +fun ViewGroup.addChips(names: List, onCloseIconClick: (Int) -> (Unit)) { + if (childCount > 0) { + removeAllViews() + } + + val layoutParams = ViewGroup.MarginLayoutParams( + ViewGroup.MarginLayoutParams.WRAP_CONTENT, + ViewGroup.MarginLayoutParams.WRAP_CONTENT + ).apply { + rightMargin = context.dpToPx(4) + topMargin = context.dpToPx(4) + } + + names.forEachIndexed { index, name -> + addView( + Chip(context).apply { + text = name + setChipBackgroundColorResource(R.color.blue_100) + setEnsureMinTouchTargetSize(false) + chipMinHeight = 8f + + closeIcon = ContextCompat.getDrawable(context, R.drawable.ic_close_black) + isCloseIconVisible = true + setOnCloseIconClickListener { + onCloseIconClick(index) + } + }, layoutParams + ) + } +} + +fun View.setCustomBackgroundColor(@ColorRes color: Int) { + this.setBackgroundColor(ContextCompat.getColor(this.context, color)) +} + +fun ImageView.setCustomBackgroundDrawable(@DrawableRes drawable: Int) { + this.setImageDrawable(ContextCompat.getDrawable(this.context, drawable)) +} + +fun Context.showAlertDialog( + message: String, + positiveCallback: () -> (Unit), + negativeCallback: (() -> (Unit))? = null +) { + AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton(R.string.yes) { _, _ -> + positiveCallback.invoke() + } + .setNegativeButton(R.string.no) { _, _ -> + negativeCallback?.invoke() + } + .show() +} + +fun Context.dpToPx(dp: Int) = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resources.displayMetrics) + .roundToInt() + +@ExperimentalCoroutinesApi +fun View.clicks(): Flow = callbackFlow { + setOnClickListener { + trySend(Unit) + } + awaitClose { setOnClickListener(null) } +} \ No newline at end of file diff --git a/app/src/main/res/anim/button_down.xml b/app/src/main/res/anim/button_down.xml new file mode 100644 index 00000000..00bd62a5 --- /dev/null +++ b/app/src/main/res/anim/button_down.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/enter_anim.xml b/app/src/main/res/anim/enter_anim.xml new file mode 100644 index 00000000..3536ff2b --- /dev/null +++ b/app/src/main/res/anim/enter_anim.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/exit_anim.xml b/app/src/main/res/anim/exit_anim.xml new file mode 100644 index 00000000..5b678eb6 --- /dev/null +++ b/app/src/main/res/anim/exit_anim.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/page_fade_in.xml b/app/src/main/res/anim/page_fade_in.xml new file mode 100644 index 00000000..ea2ebd8d --- /dev/null +++ b/app/src/main/res/anim/page_fade_in.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/page_fade_out.xml b/app/src/main/res/anim/page_fade_out.xml new file mode 100644 index 00000000..7c438bab --- /dev/null +++ b/app/src/main/res/anim/page_fade_out.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/pop_enter_anim.xml b/app/src/main/res/anim/pop_enter_anim.xml new file mode 100644 index 00000000..2c7e7c3f --- /dev/null +++ b/app/src/main/res/anim/pop_enter_anim.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/pop_exit_anim.xml b/app/src/main/res/anim/pop_exit_anim.xml new file mode 100644 index 00000000..16829178 --- /dev/null +++ b/app/src/main/res/anim/pop_exit_anim.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/recyclerview_fade_in.xml b/app/src/main/res/anim/recyclerview_fade_in.xml new file mode 100644 index 00000000..089d7ff8 --- /dev/null +++ b/app/src/main/res/anim/recyclerview_fade_in.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/star_animation.xml b/app/src/main/res/anim/star_animation.xml new file mode 100644 index 00000000..153bc148 --- /dev/null +++ b/app/src/main/res/anim/star_animation.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/text_gone.xml b/app/src/main/res/anim/text_gone.xml new file mode 100644 index 00000000..c1c8d362 --- /dev/null +++ b/app/src/main/res/anim/text_gone.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/main_bnv_item_color.xml b/app/src/main/res/color/main_bnv_item_color.xml new file mode 100644 index 00000000..3f67ea4a --- /dev/null +++ b/app/src/main/res/color/main_bnv_item_color.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_stat_name.png b/app/src/main/res/drawable-hdpi/ic_stat_name.png new file mode 100644 index 00000000..2b866f4f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_name.png b/app/src/main/res/drawable-mdpi/ic_stat_name.png new file mode 100644 index 00000000..fdb4c5de Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_name.png b/app/src/main/res/drawable-xhdpi/ic_stat_name.png new file mode 100644 index 00000000..52d0a729 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_name.png b/app/src/main/res/drawable-xxhdpi/ic_stat_name.png new file mode 100644 index 00000000..b6f35c7e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png new file mode 100644 index 00000000..7d5fd5af Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_name.png differ diff --git a/app/src/main/res/drawable/bg_add_edit_friend_group.xml b/app/src/main/res/drawable/bg_add_edit_friend_group.xml new file mode 100644 index 00000000..178ac90a --- /dev/null +++ b/app/src/main/res/drawable/bg_add_edit_friend_group.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_add_edit_friend_group_pop_up.xml b/app/src/main/res/drawable/bg_add_edit_friend_group_pop_up.xml new file mode 100644 index 00000000..78fd27c5 --- /dev/null +++ b/app/src/main/res/drawable/bg_add_edit_friend_group_pop_up.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_add_edit_friend_info.xml b/app/src/main/res/drawable/bg_add_edit_friend_info.xml new file mode 100644 index 00000000..d53b9473 --- /dev/null +++ b/app/src/main/res/drawable/bg_add_edit_friend_info.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_change_image.xml b/app/src/main/res/drawable/bg_change_image.xml new file mode 100644 index 00000000..4d43f07d --- /dev/null +++ b/app/src/main/res/drawable/bg_change_image.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_details.xml b/app/src/main/res/drawable/bg_details.xml new file mode 100644 index 00000000..1faa9d0e --- /dev/null +++ b/app/src/main/res/drawable/bg_details.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_plan.xml b/app/src/main/res/drawable/bg_plan.xml new file mode 100644 index 00000000..147ee21f --- /dev/null +++ b/app/src/main/res/drawable/bg_plan.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_plan_list_item.xml b/app/src/main/res/drawable/bg_plan_list_item.xml new file mode 100644 index 00000000..1faa9d0e --- /dev/null +++ b/app/src/main/res/drawable/bg_plan_list_item.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_round_bottom_sheet.xml b/app/src/main/res/drawable/bg_round_bottom_sheet.xml new file mode 100644 index 00000000..205e8c10 --- /dev/null +++ b/app/src/main/res/drawable/bg_round_bottom_sheet.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_switch.xml b/app/src/main/res/drawable/bg_switch.xml new file mode 100644 index 00000000..581f1d9c --- /dev/null +++ b/app/src/main/res/drawable/bg_switch.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_switch_checked.xml b/app/src/main/res/drawable/bg_switch_checked.xml new file mode 100644 index 00000000..264fc2d8 --- /dev/null +++ b/app/src/main/res/drawable/bg_switch_checked.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_switch_unchecked.xml b/app/src/main/res/drawable/bg_switch_unchecked.xml new file mode 100644 index 00000000..769a73bb --- /dev/null +++ b/app/src/main/res/drawable/bg_switch_unchecked.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/button_favorite.xml b/app/src/main/res/drawable/button_favorite.xml new file mode 100644 index 00000000..7af38e15 --- /dev/null +++ b/app/src/main/res/drawable/button_favorite.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml new file mode 100644 index 00000000..eb232541 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_add_24_white.xml b/app/src/main/res/drawable/ic_baseline_add_24_white.xml new file mode 100644 index 00000000..5707607f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_add_24_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml new file mode 100644 index 00000000..c5c44f82 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_back_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml new file mode 100644 index 00000000..a4c0e9ed --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_arrow_drop_down_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_build_24.xml b/app/src/main/res/drawable/ic_baseline_build_24.xml new file mode 100644 index 00000000..f82c6224 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_build_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml b/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml new file mode 100644 index 00000000..83fd39c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_calendar_today_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_call_24.xml b/app/src/main/res/drawable/ic_baseline_call_24.xml new file mode 100644 index 00000000..b6c3abeb --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_call_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_check_24.xml b/app/src/main/res/drawable/ic_baseline_check_24.xml new file mode 100644 index 00000000..c60f0546 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_check_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_clear_24.xml b/app/src/main/res/drawable/ic_baseline_clear_24.xml new file mode 100644 index 00000000..16d6d37d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_clear_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_close_24.xml b/app/src/main/res/drawable/ic_baseline_close_24.xml new file mode 100644 index 00000000..9ee66207 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_close_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_favorite.xml b/app/src/main/res/drawable/ic_baseline_favorite.xml new file mode 100644 index 00000000..9d9f0b5a --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_favorite.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_groups_24.xml b/app/src/main/res/drawable/ic_baseline_groups_24.xml new file mode 100644 index 00000000..7c36ad75 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_groups_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_image_24.xml b/app/src/main/res/drawable/ic_baseline_image_24.xml new file mode 100644 index 00000000..e96be0c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_image_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml b/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml new file mode 100644 index 00000000..84ce2cb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_import_contacts_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml new file mode 100644 index 00000000..884bee14 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml new file mode 100644 index 00000000..9b15755e --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_keyboard_arrow_up_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_library_books_24.xml b/app/src/main/res/drawable/ic_baseline_library_books_24.xml new file mode 100644 index 00000000..d4dbc643 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_library_books_24.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_more_vert_24.xml b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml new file mode 100644 index 00000000..bf1f39b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_more_vert_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_not_favorite.xml b/app/src/main/res/drawable/ic_baseline_not_favorite.xml new file mode 100644 index 00000000..80a3eebc --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_not_favorite.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_notifications_24.xml b/app/src/main/res/drawable/ic_baseline_notifications_24.xml new file mode 100644 index 00000000..7d49f0ba --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_notifications_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_pen.xml b/app/src/main/res/drawable/ic_baseline_pen.xml new file mode 100644 index 00000000..2844bafe --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_pen.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_perm_contact_calendar_24.xml b/app/src/main/res/drawable/ic_baseline_perm_contact_calendar_24.xml new file mode 100644 index 00000000..ba2ca107 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_perm_contact_calendar_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_person_24.xml b/app/src/main/res/drawable/ic_baseline_person_24.xml new file mode 100644 index 00000000..6bdced2d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_person_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_person_add_24.xml b/app/src/main/res/drawable/ic_baseline_person_add_24.xml new file mode 100644 index 00000000..8106579b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_person_add_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_remove_24.xml b/app/src/main/res/drawable/ic_baseline_remove_24.xml new file mode 100644 index 00000000..1f55b25e --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_remove_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml new file mode 100644 index 00000000..07b76d62 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_search_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_security_24.xml b/app/src/main/res/drawable/ic_baseline_security_24.xml new file mode 100644 index 00000000..9fbb5d53 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_security_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml new file mode 100644 index 00000000..41a82ede --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_settings_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_calendar.xml b/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 00000000..c72dbadc --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..bb4cf101 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_check_green.xml b/app/src/main/res/drawable/ic_check_green.xml new file mode 100644 index 00000000..c60f0546 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_green.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_close_black.xml b/app/src/main/res/drawable/ic_close_black.xml new file mode 100644 index 00000000..2eccf426 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_black.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_contact_launcher_background.xml b/app/src/main/res/drawable/ic_contact_launcher_background.xml new file mode 100644 index 00000000..ca3826a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_contact_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 00000000..b041a837 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_green.xml b/app/src/main/res/drawable/ic_delete_green.xml new file mode 100644 index 00000000..25fd9f24 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_green.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 00000000..c8fd4c20 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_green.xml b/app/src/main/res/drawable/ic_edit_green.xml new file mode 100644 index 00000000..38984629 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_green.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..ecce3d4e --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_send_green.xml b/app/src/main/res/drawable/ic_send_green.xml new file mode 100644 index 00000000..c45ee91e --- /dev/null +++ b/app/src/main/res/drawable/ic_send_green.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/intro0.png b/app/src/main/res/drawable/intro0.png new file mode 100644 index 00000000..bab2b859 Binary files /dev/null and b/app/src/main/res/drawable/intro0.png differ diff --git a/app/src/main/res/drawable/intro1.png b/app/src/main/res/drawable/intro1.png new file mode 100644 index 00000000..d67a1936 Binary files /dev/null and b/app/src/main/res/drawable/intro1.png differ diff --git a/app/src/main/res/drawable/intro2.png b/app/src/main/res/drawable/intro2.png new file mode 100644 index 00000000..3280cba1 Binary files /dev/null and b/app/src/main/res/drawable/intro2.png differ diff --git a/app/src/main/res/drawable/intro3.png b/app/src/main/res/drawable/intro3.png new file mode 100644 index 00000000..02adbf4f Binary files /dev/null and b/app/src/main/res/drawable/intro3.png differ diff --git a/app/src/main/res/drawable/photo.png b/app/src/main/res/drawable/photo.png new file mode 100644 index 00000000..b996bac1 Binary files /dev/null and b/app/src/main/res/drawable/photo.png differ diff --git a/app/src/main/res/drawable/round_image.xml b/app/src/main/res/drawable/round_image.xml new file mode 100644 index 00000000..de4cd27e --- /dev/null +++ b/app/src/main/res/drawable/round_image.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_search_friend_list_edit_text.xml b/app/src/main/res/drawable/shape_search_friend_list_edit_text.xml new file mode 100644 index 00000000..a724ca19 --- /dev/null +++ b/app/src/main/res/drawable/shape_search_friend_list_edit_text.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..40e70c6f --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..d3a129b7 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_on_boarding.xml b/app/src/main/res/layout/activity_on_boarding.xml new file mode 100644 index 00000000..bd695039 --- /dev/null +++ b/app/src/main/res/layout/activity_on_boarding.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_password.xml b/app/src/main/res/layout/activity_password.xml new file mode 100644 index 00000000..dea04a7e --- /dev/null +++ b/app/src/main/res/layout/activity_password.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 00000000..c566ce0e --- /dev/null +++ b/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_group.xml b/app/src/main/res/layout/dialog_group.xml new file mode 100644 index 00000000..6c5adb77 --- /dev/null +++ b/app/src/main/res/layout/dialog_group.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + +