diff --git a/README.md b/README.md
new file mode 100644
index 000000000..c3e216567
--- /dev/null
+++ b/README.md
@@ -0,0 +1,113 @@
+
+
+# YELL:O
+
+
+```
+투표로 관계의 재미를 찾아가는 서비스, YELL:O
+```
+
+
+## CONTRIBUTORS
+| 이강민
([@kkk5474096](https://github.com/kkk5474096)) | 전채연
([@b1urrrr](https://github.com/b1urrrr)) | 김상호
([@Marchbreeze](https://github.com/Marchbreeze)) | 박민주
([@minju1459](https://github.com/minju1459)) |
+|:---------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------:|
+| | | | |
+| `내 쪽지`
`푸시알림` | `투표(옐로하기)` | `프로필`
`추천친구`
`타임라인`
`인앱결제` | `온보딩`
`튜토리얼` |
+
+
+## TECH STACK
+- Android App Architecture
+- Multi-Module
+- Hilt
+- Coroutine
+- Paging3
+- Data Binding
+- Timber, Coil, Lottie, Shimmer
+- Firebase Cloud Messaging
+- Kakao Open API
+- Google Play Billing API (in-app purchases API)
+
+
+## SCREENSHOTS
+| 뷰 | 1 | 2 | 3 | 4 |
+|:-------------:|:---------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------:|
+| 스플래쉬
로그인 | | | | |
+| 온보딩
튜토리얼 | | | | |
+| 투표 (옐로하기) | | | | |
+| 내 쪽지
결제 | | | | |
+| 추천친구 | | | | |
+| 타임라인
프로필 | | | | |
+
+
+## COMMON TYPE
+- ✨ **[FEAT]** : 새로운 기능 구현
+- ✅ **[MOD]** : 코드 수정 및 내부 파일 수정
+- ➕ **[ADD]** : 부수적인 코드 추가 및 라이브러리 추가, 새로운 파일 생성
+- 🎀 **[CHORE]** : 버전 코드 수정, 패키지 구조 변경, 타입 및 변수명 변경 등의 작은 작업
+- ⚰️ **[DEL]** : 쓸모없는 코드나 파일 삭제
+- 💄 **[UI]** : UI 작업
+- 🔨 **[FIX]** : 버그 및 오류 해결
+- 🚑️ **[HOTFIX]** : issue나 QA에서 문의된 급한 버그 및 오류 해결
+- 🔀 **[MERGE]** : 다른 브랜치와의 MERGE
+- 🚚 **[MOVE]** : 프로젝트 내 파일이나 코드의 이동
+- ⏪️ **[RENAME]** : 파일 이름 변경
+- ♻️ **[REFACTOR]** : 전면 수정
+- 📝 **[DOCS]** : README나 WIKI 등의 문서 개정
+
+
+## [COMMIT CONVENTION](https://www.notion.so/yell0/Github-Convention-daa23c4e64ad4e0b9cb5f720f4694732?pvs=4#a41dad6a57ae4dacb85d271163f82128)
+```
+[커밋유형/#이슈번호] 작업내용
+```
+
+
+## [BRANCH CONVENTION](https://www.notion.so/yell0/Branch-Convention-1881bd8691654b0cbeba3ef3f94c7cdb)
+```
+브랜치유형/#이슈번호-작업내용
+```
+
+
+## [ISSUE CONVENTION](https://www.notion.so/yell0/Github-Convention-daa23c4e64ad4e0b9cb5f720f4694732?pvs=4#e338546983c1443d8d65af3c33613ce4)
+```
+[작업유형] 뷰이름 / 작업내용
+```
+
+
+## [PR CONVENTION](https://www.notion.so/yell0/Github-Convention-daa23c4e64ad4e0b9cb5f720f4694732?pvs=4#d94cc0666b754b73967f95ea7810bd72)
+```
+[작업유형/#이슈번호] 뷰이름 / 작업내용
+```
+
+
+## [CODING CONVENTION](https://yell0.notion.site/a1d166eeeee04dddb31fd6f1d224a46a?v=0ecae9f587064ce4880cc32063ae7dbe&pvs=4)
+- [Kotlin 공식 컨벤션](https://kotlinlang.org/docs/coding-conventions.html) 준수
+
+
+## [MODULE & PACKAGE CONVENTION]()
+```
+🗃️app
+ ┣ 📂di
+ ┣ 📂presentation
+🗃️buildSrc
+🗃️core-ui
+ ┣ 📂base
+ ┣ 📂context
+ ┣ 📂fragment
+ ┣ 📂intent
+ ┣ 📂view
+🗃️data
+ ┣ 📂datasource
+ ┣ 📂local
+ ┣ 📂model
+ ┃ ┣ 📂response
+ ┃ ┣ 📂request
+ ┣ 📂repository
+ ┣ 📂remote
+ ┃ ┣ 📂interceptor
+ ┃ ┣ 📂service
+ ┣ 📂util
+🗃️domain
+ ┣ 📂entity
+ ┣ 📂repository
+```
+
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /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 000000000..756271011
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,11 @@
+plugins {
+ id("yello.android.application")
+ id("yello.android.androidHilt")
+ alias(libs.plugins.androidKotlin)
+}
+
+dependencies {
+ implementation(project(":core-ui"))
+ implementation(project(":data"))
+ implementation(project(":domain"))
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 000000000..ff59496d8
--- /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/release/output-metadata.json b/app/release/output-metadata.json
new file mode 100644
index 000000000..d93f3a06f
--- /dev/null
+++ b/app/release/output-metadata.json
@@ -0,0 +1,20 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "com.el.yello",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 16,
+ "versionName": "1.1",
+ "outputFile": "app-release.apk"
+ }
+ ],
+ "elementType": "File"
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/el/yello/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/el/yello/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..e59230e03
--- /dev/null
+++ b/app/src/androidTest/java/com/el/yello/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.yello
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.yello", appContext.packageName)
+ }
+}
diff --git a/app/src/debug/java/com/el/yello/FlipperSetUp.kt b/app/src/debug/java/com/el/yello/FlipperSetUp.kt
new file mode 100644
index 000000000..815f9e15c
--- /dev/null
+++ b/app/src/debug/java/com/el/yello/FlipperSetUp.kt
@@ -0,0 +1,30 @@
+package com.el.yello
+
+import android.app.Application
+import com.facebook.flipper.android.AndroidFlipperClient
+import com.facebook.flipper.android.utils.FlipperUtils
+import com.facebook.flipper.plugins.inspector.DescriptorMapping
+import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin
+import com.facebook.flipper.plugins.network.FlipperOkhttpInterceptor
+import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
+import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
+import com.facebook.soloader.SoLoader
+import okhttp3.OkHttpClient
+
+private val flipperNetworkPlugin = NetworkFlipperPlugin()
+
+fun Application.setUpFlipper() {
+ if (BuildConfig.DEBUG && FlipperUtils.shouldEnableFlipper(this)) {
+ SoLoader.init(this, false)
+ val client = AndroidFlipperClient.getInstance(this).apply {
+ addPlugin(InspectorFlipperPlugin(this@setUpFlipper, DescriptorMapping.withDefaults()))
+ addPlugin(flipperNetworkPlugin)
+ addPlugin(SharedPreferencesFlipperPlugin(this@setUpFlipper, packageName))
+ }
+ client.start()
+ }
+}
+
+fun OkHttpClient.Builder.addFlipperNetworkPlugin(): OkHttpClient.Builder {
+ return addNetworkInterceptor(FlipperOkhttpInterceptor(flipperNetworkPlugin))
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..02ac32491
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_yello_launcher-playstore.png b/app/src/main/ic_yello_launcher-playstore.png
new file mode 100644
index 000000000..e4dd0d3c9
Binary files /dev/null and b/app/src/main/ic_yello_launcher-playstore.png differ
diff --git a/app/src/main/java/com/el/yello/MyApp.kt b/app/src/main/java/com/el/yello/MyApp.kt
new file mode 100644
index 000000000..796651393
--- /dev/null
+++ b/app/src/main/java/com/el/yello/MyApp.kt
@@ -0,0 +1,33 @@
+package com.el.yello
+
+import android.app.Application
+import com.amplitude.api.Amplitude
+import com.el.yello.BuildConfig.AMPLITUDE_API_KEY
+import com.el.yello.BuildConfig.NATIVE_APP_KEY
+import com.el.yello.presentation.util.ResolutionMetrics
+import com.kakao.sdk.common.KakaoSdk
+import dagger.hilt.android.HiltAndroidApp
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltAndroidApp
+class MyApp : Application() {
+ @Inject
+ lateinit var metrics: ResolutionMetrics
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
+ setUpFlipper()
+ KakaoSdk.init(this, NATIVE_APP_KEY)
+ resolutionMetrics = metrics
+
+ Amplitude.getInstance().initialize(this, AMPLITUDE_API_KEY)
+ .enableForegroundTracking(this)
+ }
+
+ companion object {
+ lateinit var resolutionMetrics: ResolutionMetrics
+ }
+}
diff --git a/app/src/main/java/com/el/yello/config/YelloMessagingService.kt b/app/src/main/java/com/el/yello/config/YelloMessagingService.kt
new file mode 100644
index 000000000..af7ad592b
--- /dev/null
+++ b/app/src/main/java/com/el/yello/config/YelloMessagingService.kt
@@ -0,0 +1,105 @@
+package com.el.yello.config
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.getSystemService
+import com.el.yello.R
+import com.el.yello.presentation.main.MainActivity
+import com.example.data.model.request.auth.toDeviceToken
+import com.example.data.remote.service.AuthService
+import com.example.domain.YelloDataStore
+import com.google.firebase.messaging.FirebaseMessagingService
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.Random
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class YelloMessagingService : FirebaseMessagingService() {
+ @Inject
+ lateinit var dataStore: YelloDataStore
+
+ @Inject
+ lateinit var authService: AuthService
+
+ override fun onNewToken(token: String) {
+ super.onNewToken(token)
+
+ if (dataStore.userToken != "") {
+ GlobalScope.launch {
+ runCatching {
+ authService.putDeviceToken(
+ token.toDeviceToken()
+ )
+ }.onFailure(Timber::e)
+ }
+ }
+ }
+
+ override fun handleIntent(intent: Intent?) {
+ intent?.let {
+ val responseMessage = Message("", "", "")
+ responseMessage.title = intent.getStringExtra("title").toString()
+ responseMessage.body = intent.getStringExtra("body").toString()
+ responseMessage.type = intent.getStringExtra("type").toString()
+ responseMessage.path = intent.getStringExtra("path")
+ responseMessage.badge = intent.getStringExtra("badge")?.toInt()
+
+ if (responseMessage.title != EMPTY) {
+ sendNotificationAlarm(responseMessage)
+ }
+ }
+ }
+
+ private fun sendNotificationAlarm(message: Message) {
+ val notifyId = Random().nextInt()
+ val intent = MainActivity.getIntent(this, message.type, message.path)
+ .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ val pendingIntent =
+ PendingIntent.getActivity(
+ this,
+ notifyId,
+ intent,
+ PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_MUTABLE
+ )
+
+ val channelId = getString(R.string.default_notification_channel_id)
+
+ val notificationBuilder =
+ NotificationCompat.Builder(this, channelId).setSmallIcon(R.mipmap.ic_yello_launcher)
+ .apply {
+ setContentTitle(message.title).setContentText(message.body)
+ setPriority(NotificationManagerCompat.IMPORTANCE_HIGH).setAutoCancel(true)
+ setContentIntent(pendingIntent)
+ if (message.badge != null) setNumber(message.badge!!)
+ }
+
+ val notificationManager = getSystemService()
+ val channel = NotificationChannel(
+ channelId, channelId, NotificationManager.IMPORTANCE_HIGH
+ )
+
+ notificationManager?.run {
+ createNotificationChannel(channel)
+ notify(notifyId, notificationBuilder.build())
+ }
+ }
+
+ private data class Message(
+ var title: String,
+ var body: String,
+ var type: String,
+ var path: String? = null,
+ var badge: Int? = null
+ )
+
+ companion object {
+ const val EMPTY = "null"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/di/AppModule.kt b/app/src/main/java/com/el/yello/di/AppModule.kt
new file mode 100644
index 000000000..f789f74ea
--- /dev/null
+++ b/app/src/main/java/com/el/yello/di/AppModule.kt
@@ -0,0 +1,32 @@
+package com.el.yello.di
+
+import android.app.Application
+import android.content.Context
+import com.example.data.util.FileParser
+import com.el.yello.presentation.util.ResolutionMetrics
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AppModule {
+ @Provides
+ @Singleton
+ @ApplicationContext
+ fun provideApplication(application: Application) = application
+
+ @Provides
+ @Singleton
+ fun provideFileParser(
+ @ApplicationContext context: Context,
+ ): FileParser = FileParser(context)
+
+ @Provides
+ @Singleton
+ fun provideResolutionMetrics(@ApplicationContext context: Application) =
+ ResolutionMetrics(context)
+}
diff --git a/app/src/main/java/com/el/yello/di/DataSourceModule.kt b/app/src/main/java/com/el/yello/di/DataSourceModule.kt
new file mode 100644
index 000000000..b50e8f3e3
--- /dev/null
+++ b/app/src/main/java/com/el/yello/di/DataSourceModule.kt
@@ -0,0 +1,74 @@
+package com.el.yello.di
+
+import com.example.data.datasource.AuthDataSource
+import com.example.data.datasource.NoticeDataSource
+import com.example.data.datasource.OnboardingDataSource
+import com.example.data.datasource.PayDataSource
+import com.example.data.datasource.ProfileDataSource
+import com.example.data.datasource.RecommendDataSource
+import com.example.data.datasource.SearchDataSource
+import com.example.data.datasource.VoteDataSource
+import com.example.data.datasource.YelloDataSource
+import com.example.data.datasource.remote.AuthDataSourceImpl
+import com.example.data.datasource.remote.NoticeDataSourceImpl
+import com.example.data.datasource.remote.OnboardingDataSourceImpl
+import com.example.data.datasource.remote.PayDataSourceImpl
+import com.example.data.datasource.remote.ProfileDataSourceImpl
+import com.example.data.datasource.remote.RecommendDataSourceImpl
+import com.example.data.datasource.remote.SearchDataSourceImpl
+import com.example.data.datasource.remote.VoteDataSourceImpl
+import com.example.data.datasource.remote.YelloDataSourceImpl
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataSourceModule {
+ @Provides
+ @Singleton
+ fun provideYelloDataSource(yelloDataSourceImpl: YelloDataSourceImpl): YelloDataSource =
+ yelloDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun provideVoteDataSource(voteDataSourceImpl: VoteDataSourceImpl): VoteDataSource =
+ voteDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun provideOnboardingDataSource(onboardingDataSourceImpl: OnboardingDataSourceImpl): OnboardingDataSource =
+ onboardingDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun provideProfileDataSource(profileDataSourceImpl: ProfileDataSourceImpl): ProfileDataSource =
+ profileDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun provideRecommendDataSource(recommendDataSourceImpl: RecommendDataSourceImpl): RecommendDataSource =
+ recommendDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun provideSearchDataSource(searchDataSourceImpl: SearchDataSourceImpl): SearchDataSource =
+ searchDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun providePayDataSource(payDataSourceImpl: PayDataSourceImpl): PayDataSource =
+ payDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun provideAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource =
+ authDataSourceImpl
+
+ @Provides
+ @Singleton
+ fun provideNoticeDataSource(noticeDataSourceImpl: NoticeDataSourceImpl): NoticeDataSource =
+ noticeDataSourceImpl
+}
diff --git a/app/src/main/java/com/el/yello/di/DataStoreModule.kt b/app/src/main/java/com/el/yello/di/DataStoreModule.kt
new file mode 100644
index 000000000..d77d731d5
--- /dev/null
+++ b/app/src/main/java/com/el/yello/di/DataStoreModule.kt
@@ -0,0 +1,83 @@
+package com.el.yello.di
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import com.el.yello.BuildConfig
+import com.example.data.local.qualifier.App
+import com.example.data.local.qualifier.User
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import java.security.GeneralSecurityException
+import java.security.KeyStore
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+ private const val APP_PREFERENCES_NAME = "APP_DATA"
+
+ @Provides
+ @Singleton
+ @User
+ fun provideUserPreferences(
+ @ApplicationContext context: Context,
+ ): SharedPreferences = if (BuildConfig.DEBUG) {
+ context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE)
+ } else {
+ try {
+ createEncryptedSharedPreferences(context.packageName, context)
+ } catch (e: GeneralSecurityException) {
+ deleteMasterKeyEntry()
+ deleteExistingPref(context.packageName, context)
+ createEncryptedSharedPreferences(context.packageName, context)
+ }
+ }
+
+ @Provides
+ @Singleton
+ @App
+ fun provideAppPreferences(
+ @ApplicationContext context: Context,
+ ): SharedPreferences = if (BuildConfig.DEBUG) {
+ context.getSharedPreferences(APP_PREFERENCES_NAME, Context.MODE_PRIVATE)
+ } else {
+ try {
+ createEncryptedSharedPreferences(APP_PREFERENCES_NAME, context)
+ } catch (e: GeneralSecurityException) {
+ deleteMasterKeyEntry()
+ deleteExistingPref(APP_PREFERENCES_NAME, context)
+ createEncryptedSharedPreferences(APP_PREFERENCES_NAME, context)
+ }
+ }
+
+ private fun deleteExistingPref(fileName: String, context: Context) {
+ context.deleteSharedPreferences(fileName)
+ }
+
+ private fun deleteMasterKeyEntry() {
+ KeyStore.getInstance("AndroidKeyStore").apply {
+ load(null)
+ deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
+ }
+ }
+
+ private fun createEncryptedSharedPreferences(
+ fileName: String,
+ context: Context,
+ ): SharedPreferences {
+ return EncryptedSharedPreferences.create(
+ context,
+ fileName,
+ MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build(),
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ }
+}
diff --git a/app/src/main/java/com/el/yello/di/RepositoryModule.kt b/app/src/main/java/com/el/yello/di/RepositoryModule.kt
new file mode 100644
index 000000000..f3d83dc7d
--- /dev/null
+++ b/app/src/main/java/com/el/yello/di/RepositoryModule.kt
@@ -0,0 +1,74 @@
+package com.el.yello.di
+
+import com.example.data.repository.AuthRepositoryImpl
+import com.example.data.repository.NoticeRepositoryImpl
+import com.example.data.repository.OnboardingRepositoryImpl
+import com.example.data.repository.PayRepositoryImpl
+import com.example.data.repository.ProfileRepositoryImpl
+import com.example.data.repository.RecommendRepositoryImpl
+import com.example.data.repository.SearchRepositoryImpl
+import com.example.data.repository.VoteRepositoryImpl
+import com.example.data.repository.YelloRepositoryImpl
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.NoticeRepository
+import com.example.domain.repository.OnboardingRepository
+import com.example.domain.repository.PayRepository
+import com.example.domain.repository.ProfileRepository
+import com.example.domain.repository.RecommendRepository
+import com.example.domain.repository.SearchRepository
+import com.example.domain.repository.VoteRepository
+import com.example.domain.repository.YelloRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RepositoryModule {
+ @Provides
+ @Singleton
+ fun provideYelloRepository(yelloRepositoryImpl: YelloRepositoryImpl): YelloRepository =
+ yelloRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun provideVoteRepository(voteRepositoryImpl: VoteRepositoryImpl): VoteRepository =
+ voteRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun provideOnboardingRepository(onboardingRepositoryImpl: OnboardingRepositoryImpl): OnboardingRepository =
+ onboardingRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun provideProfileRepository(profileRepositoryImpl: ProfileRepositoryImpl): ProfileRepository =
+ profileRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun provideRecommendRepository(recommendRepositoryImpl: RecommendRepositoryImpl): RecommendRepository =
+ recommendRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun provideSearchRepository(searchRepositoryImpl: SearchRepositoryImpl): SearchRepository =
+ searchRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun provideAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository =
+ authRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun providePayRepository(payRepositoryImpl: PayRepositoryImpl): PayRepository =
+ payRepositoryImpl
+
+ @Provides
+ @Singleton
+ fun provideNoticeRepository(noticeRepositoryImpl: NoticeRepositoryImpl): NoticeRepository =
+ noticeRepositoryImpl
+}
diff --git a/app/src/main/java/com/el/yello/di/RetrofitModule.kt b/app/src/main/java/com/el/yello/di/RetrofitModule.kt
new file mode 100644
index 000000000..166f01944
--- /dev/null
+++ b/app/src/main/java/com/el/yello/di/RetrofitModule.kt
@@ -0,0 +1,94 @@
+package com.el.yello.di
+
+import com.el.yello.BuildConfig.BASE_URL
+import com.el.yello.addFlipperNetworkPlugin
+import com.el.yello.di.qualifier.Auth
+import com.el.yello.di.qualifier.Logger
+import com.example.data.local.YelloDataStoreImpl
+import com.example.data.remote.interceptor.AuthInterceptor
+import com.example.domain.YelloDataStore
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import org.json.JSONObject
+import retrofit2.Converter
+import retrofit2.Retrofit
+import timber.log.Timber
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RetrofitModule {
+ private const val APPLICATION_JSON = "application/json"
+
+ @Provides
+ @Singleton
+ fun provideJson(): Json = Json {
+ ignoreUnknownKeys = true
+ prettyPrint = true
+ }
+
+ @Provides
+ @Singleton
+ fun provideDataStore(yelloDataStore: YelloDataStoreImpl): YelloDataStore = yelloDataStore
+
+ @Provides
+ @Singleton
+ fun provideJsonConverter(json: Json): Converter.Factory =
+ json.asConverterFactory(APPLICATION_JSON.toMediaType())
+
+ @Provides
+ @Singleton
+ @Logger
+ fun provideHttpLoggingInterceptor(): Interceptor = HttpLoggingInterceptor { message ->
+ when {
+ message.startsWith("{") && message.endsWith("}") -> {
+ Timber.tag("okhttp").d(JSONObject(message).toString(4))
+ }
+
+ message.startsWith("[") && message.endsWith("]") -> {
+ Timber.tag("okhttp").d(JSONObject(message).toString(4))
+ }
+
+ else -> {
+ Timber.tag("okhttp").d("CONNECTION INFO -> $message")
+ }
+ }
+ }.apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+
+ @Provides
+ @Singleton
+ @Auth
+ fun provideAuthInterceptor(interceptor: AuthInterceptor): Interceptor = interceptor
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(
+ @Logger loggingInterceptor: Interceptor,
+ @Auth authInterceptor: Interceptor,
+ ): OkHttpClient = OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .addInterceptor(authInterceptor)
+ .addFlipperNetworkPlugin()
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(
+ client: OkHttpClient,
+ factory: Converter.Factory,
+ ): Retrofit = Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .client(client)
+ .addConverterFactory(factory)
+ .build()
+}
diff --git a/app/src/main/java/com/el/yello/di/ServiceModule.kt b/app/src/main/java/com/el/yello/di/ServiceModule.kt
new file mode 100644
index 000000000..2be3a7d7b
--- /dev/null
+++ b/app/src/main/java/com/el/yello/di/ServiceModule.kt
@@ -0,0 +1,72 @@
+package com.el.yello.di
+
+import com.example.data.remote.service.AuthService
+import com.example.data.remote.service.LookService
+import com.example.data.remote.service.NoticeService
+import com.example.data.remote.service.OnboardingService
+import com.example.data.remote.service.PayService
+import com.example.data.remote.service.ProfileService
+import com.example.data.remote.service.RecommendService
+import com.example.data.remote.service.SearchService
+import com.example.data.remote.service.VoteService
+import com.example.data.remote.service.YelloService
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import retrofit2.Retrofit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ServiceModule {
+ @Provides
+ @Singleton
+ fun provideYelloService(retrofit: Retrofit): YelloService =
+ retrofit.create(YelloService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideVoteService(retrofit: Retrofit): VoteService =
+ retrofit.create(VoteService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideOnboardingService(retrofit: Retrofit): OnboardingService =
+ retrofit.create(OnboardingService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideProfileService(retrofit: Retrofit): ProfileService =
+ retrofit.create(ProfileService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideRecommendService(retrofit: Retrofit): RecommendService =
+ retrofit.create(RecommendService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideSearchService(retrofit: Retrofit): SearchService =
+ retrofit.create(SearchService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideLookService(retrofit: Retrofit): LookService =
+ retrofit.create(LookService::class.java)
+
+ @Provides
+ @Singleton
+ fun providePayService(retrofit: Retrofit): PayService =
+ retrofit.create(PayService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideAuthService(retrofit: Retrofit): AuthService =
+ retrofit.create(AuthService::class.java)
+
+ @Provides
+ @Singleton
+ fun provideNoticeService(retrofit: Retrofit): NoticeService =
+ retrofit.create(NoticeService::class.java)
+}
diff --git a/app/src/main/java/com/el/yello/di/qualifier/InterceptorQualifier.kt b/app/src/main/java/com/el/yello/di/qualifier/InterceptorQualifier.kt
new file mode 100644
index 000000000..a93443302
--- /dev/null
+++ b/app/src/main/java/com/el/yello/di/qualifier/InterceptorQualifier.kt
@@ -0,0 +1,11 @@
+package com.el.yello.di.qualifier
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class Logger
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class Auth
diff --git a/app/src/main/java/com/el/yello/presentation/auth/SignInActivity.kt b/app/src/main/java/com/el/yello/presentation/auth/SignInActivity.kt
new file mode 100644
index 000000000..c5d197cde
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/auth/SignInActivity.kt
@@ -0,0 +1,197 @@
+package com.el.yello.presentation.auth
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.activity.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.ActivitySignInBinding
+import com.el.yello.presentation.auth.SignInViewModel.Companion.FRIEND_LIST
+import com.el.yello.presentation.main.MainActivity
+import com.el.yello.presentation.onboarding.activity.EditNameActivity
+import com.el.yello.presentation.onboarding.activity.GetAlarmActivity
+import com.el.yello.presentation.onboarding.fragment.checkName.CheckNameDialog
+import com.el.yello.presentation.tutorial.TutorialAActivity
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@AndroidEntryPoint
+class SignInActivity : BindingActivity(R.layout.activity_sign_in) {
+
+ private val viewModel by viewModels()
+
+ private var checkNameDialog: CheckNameDialog? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initSignInBtnListener()
+ viewModel.getDeviceToken()
+ observeDeviceTokenError()
+ observeAppLoginError()
+ observeKakaoUserInfoResult()
+ observeFriendsListValidState()
+ observeChangeTokenResult()
+ observeUserDataState()
+ }
+
+ private fun initSignInBtnListener() {
+ binding.btnSignIn.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties("click_onboarding_kakao")
+ viewModel.startLogInWithKakao(this)
+ }
+ }
+
+ private fun observeDeviceTokenError() {
+ viewModel.getDeviceTokenError.flowWithLifecycle(lifecycle).onEach { error ->
+ if (error) toast(getString(R.string.sign_in_error_connection))
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeAppLoginError() {
+ viewModel.isAppLoginAvailable.flowWithLifecycle(lifecycle).onEach { available ->
+ if (!available) viewModel.startLogInWithKakao(this)
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeChangeTokenResult() {
+ viewModel.postChangeTokenResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (!result) toast(getString(R.string.sign_in_error_connection))
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeKakaoUserInfoResult() {
+ viewModel.getKakaoInfoResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (!result) yelloSnackbar(binding.root, getString(R.string.msg_error))
+ }.launchIn(lifecycleScope)
+ }
+
+ // ChangeToken Failure -> 카카오에 등록된 유저 정보 받아온 후 친구목록 동의 or 온보딩 화면으로 이동
+ private fun observeFriendsListValidState() {
+ viewModel.getKakaoValidState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ val friendScope = state.data.find { it.id == FRIEND_LIST }
+ if (friendScope?.agreed == true) {
+ startCheckNameDialog()
+ } else {
+ startSocialSyncActivity()
+ }
+ }
+
+ is UiState.Failure -> yelloSnackbar(binding.root, getString(R.string.msg_error))
+
+ is UiState.Empty -> return@onEach
+
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ // ChangeToken Success -> 서버에 등록된 유저 정보가 있는지 확인 후 메인 액티비티로 이동
+ private fun observeUserDataState() {
+ viewModel.getUserProfileState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ if (viewModel.getIsFirstLoginData()) {
+ if (viewModel.isResigned) {
+ startActivity(TutorialAActivity.newIntent(this, false))
+ } else {
+ startMainActivity()
+ }
+ } else {
+ startActivity(Intent(this, GetAlarmActivity::class.java))
+ }
+ }
+
+ is UiState.Failure -> yelloSnackbar(binding.root, getString(R.string.msg_error))
+
+ is UiState.Empty -> return@onEach
+
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun startMainActivity() {
+ Intent(this, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ finish()
+ }
+
+ private fun startSocialSyncActivity() {
+ Intent(this, SocialSyncActivity::class.java).apply {
+ addPutExtra()
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ finish()
+ }
+
+ private fun startCheckNameDialog() {
+ val bundle = Bundle().apply { addPutExtra() }
+ if (viewModel.isUserNameBlank()) {
+ Intent(SignInActivity(), EditNameActivity::class.java).apply {
+ putExtras(bundle)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ finish()
+ } else {
+ checkNameDialog = CheckNameDialog()
+ binding.btnSignIn.visibility = View.GONE
+ binding.ivSignIn.visibility = View.GONE
+ binding.ivSignInKakao.visibility = View.GONE
+ binding.tvSignInTitle.visibility = View.GONE
+ binding.tvSignInSubtitle.visibility = View.GONE
+ checkNameDialog?.arguments = bundle
+ checkNameDialog?.show(supportFragmentManager, CHECK_NAME_DIALOG)
+ }
+ }
+
+ private fun Intent.addPutExtra() {
+ if (viewModel.checkKakaoUserInfoStored()) {
+ putExtra(EXTRA_KAKAO_ID, viewModel.kakaoUserInfo.id)
+ putExtra(EXTRA_NAME, viewModel.kakaoUserInfo.kakaoAccount?.name.orEmpty())
+ putExtra(EXTRA_GENDER, viewModel.kakaoUserInfo.kakaoAccount?.gender.toString())
+ putExtra(EXTRA_EMAIL, viewModel.kakaoUserInfo.kakaoAccount?.email.orEmpty())
+ putExtra(EXTRA_PROFILE_IMAGE, viewModel.kakaoUserInfo.kakaoAccount?.profile?.profileImageUrl.orEmpty())
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+ }
+
+ private fun Bundle.addPutExtra() {
+ if (viewModel.checkKakaoUserInfoStored()) {
+ putLong(EXTRA_KAKAO_ID, viewModel.kakaoUserInfo.id ?: 0)
+ putString(EXTRA_NAME, viewModel.kakaoUserInfo.kakaoAccount?.name.orEmpty())
+ putString(EXTRA_GENDER, viewModel.kakaoUserInfo.kakaoAccount?.gender.toString())
+ putString(EXTRA_EMAIL, viewModel.kakaoUserInfo.kakaoAccount?.email.orEmpty())
+ putString(EXTRA_PROFILE_IMAGE, viewModel.kakaoUserInfo.kakaoAccount?.profile?.profileImageUrl.orEmpty())
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ checkNameDialog?.dismiss()
+ }
+
+ companion object {
+ const val EXTRA_KAKAO_ID = "KAKAO_ID"
+ const val EXTRA_EMAIL = "KAKAO_EMAIL"
+ const val EXTRA_PROFILE_IMAGE = "PROFILE_IMAGE"
+ const val EXTRA_NAME = "NAME"
+ const val EXTRA_GENDER = "GENDER"
+ const val CHECK_NAME_DIALOG = "CHECK_NAME_DIALOG"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/auth/SignInViewModel.kt b/app/src/main/java/com/el/yello/presentation/auth/SignInViewModel.kt
new file mode 100644
index 000000000..0a32d5615
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/auth/SignInViewModel.kt
@@ -0,0 +1,207 @@
+package com.el.yello.presentation.auth
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.domain.entity.AuthTokenRequestModel
+import com.example.domain.entity.ProfileUserModel
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.OnboardingRepository
+import com.example.domain.repository.ProfileRepository
+import com.example.ui.view.UiState
+import com.google.firebase.messaging.FirebaseMessaging
+import com.kakao.sdk.auth.model.OAuthToken
+import com.kakao.sdk.common.model.ClientError
+import com.kakao.sdk.common.model.ClientErrorCause
+import com.kakao.sdk.user.UserApiClient
+import com.kakao.sdk.user.model.Scope
+import com.kakao.sdk.user.model.User
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import javax.inject.Inject
+
+@HiltViewModel
+class SignInViewModel @Inject constructor(
+ private val onboardingRepository: OnboardingRepository,
+ private val authRepository: AuthRepository,
+ private val profileRepository: ProfileRepository
+) : ViewModel() {
+
+ private val _postChangeTokenResult = MutableSharedFlow()
+ val postChangeTokenResult: SharedFlow = _postChangeTokenResult
+
+ private val _getUserProfileState = MutableStateFlow>(UiState.Empty)
+ val getUserProfileState: StateFlow> = _getUserProfileState
+
+ private val _getKakaoInfoResult = MutableSharedFlow()
+ val getKakaoInfoResult: SharedFlow = _getKakaoInfoResult
+
+ lateinit var kakaoUserInfo: User
+
+ private val _getKakaoValidState = MutableStateFlow>>(UiState.Empty)
+ val getKakaoValidState: StateFlow>> = _getKakaoValidState
+
+ private val serviceTermsList = listOf(THUMBNAIL, EMAIL, FRIEND_LIST, NAME, GENDER)
+
+ private var deviceToken = String()
+
+ private val _getDeviceTokenError = MutableStateFlow(false)
+ val getDeviceTokenError: StateFlow = _getDeviceTokenError
+
+ private val _isAppLoginAvailable = MutableStateFlow(true)
+ val isAppLoginAvailable: StateFlow = _isAppLoginAvailable
+
+ var isResigned = false
+ private set
+
+ private var webLoginCallback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
+ if (error == null && token != null) {
+ changeTokenFromServer(
+ accessToken = token.accessToken,
+ deviceToken = deviceToken,
+ )
+ }
+ }
+
+ private var appLoginCallback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
+ if (error != null) {
+ if (!(error is ClientError && error.reason == ClientErrorCause.Cancelled)) {
+ _isAppLoginAvailable.value = false
+ }
+ } else if (token != null) {
+ changeTokenFromServer(
+ accessToken = token.accessToken,
+ deviceToken = deviceToken,
+ )
+ }
+ }
+
+ fun startLogInWithKakao(context: Context) {
+ if (UserApiClient.instance.isKakaoTalkLoginAvailable(context) && isAppLoginAvailable.value) {
+ UserApiClient.instance.loginWithKakaoTalk(
+ context = context,
+ callback = appLoginCallback,
+ serviceTerms = serviceTermsList,
+ )
+ } else {
+ UserApiClient.instance.loginWithKakaoAccount(
+ context = context,
+ callback = webLoginCallback,
+ serviceTerms = serviceTermsList,
+ )
+ }
+ }
+
+ private fun getUserInfoFromKakao() {
+ UserApiClient.instance.me { user, _ ->
+ try {
+ if (user != null) {
+ kakaoUserInfo = user
+ checkFriendsListValidFromKakao()
+ }
+ } catch (e: IllegalArgumentException) {
+ viewModelScope.launch {
+ _getKakaoInfoResult.emit(false)
+ }
+ }
+ }
+ }
+
+ fun checkKakaoUserInfoStored() = ::kakaoUserInfo.isInitialized
+
+ fun isUserNameBlank() =
+ !::kakaoUserInfo.isInitialized || kakaoUserInfo.kakaoAccount?.name.isNullOrEmpty()
+
+ private fun checkFriendsListValidFromKakao() {
+ val scopes = mutableListOf(FRIEND_LIST)
+ _getKakaoValidState.value = UiState.Loading
+ UserApiClient.instance.scopes(scopes) { scopeInfo, error ->
+ if (error != null) {
+ _getKakaoValidState.value = UiState.Failure(error.message.toString())
+ } else if (scopeInfo != null) {
+ _getKakaoValidState.value = UiState.Success(scopeInfo.scopes ?: listOf())
+ } else {
+ _getKakaoValidState.value = UiState.Failure(ERROR)
+ }
+ }
+ }
+
+ private fun changeTokenFromServer(
+ accessToken: String,
+ social: String = KAKAO,
+ deviceToken: String
+ ) {
+ viewModelScope.launch {
+ onboardingRepository.postTokenToServiceToken(
+ AuthTokenRequestModel(accessToken, social, deviceToken),
+ )
+ .onSuccess {
+ // 200(가입된 아이디): 온보딩 뷰 생략하고 바로 메인 화면으로 이동 위해 유저 정보 받기
+ if (it == null) {
+ _postChangeTokenResult.emit(false)
+ return@launch
+ }
+ authRepository.setAutoLogin(it.accessToken, it.refreshToken)
+ isResigned = it.isResigned
+ getUserDataFromServer()
+ }
+ .onFailure {
+ // 403, 404 : 온보딩 뷰로 이동 위해 카카오 유저 정보 얻기
+ if (it is HttpException && (it.code() == 403 || it.code() == 404)) {
+ getUserInfoFromKakao()
+ } else {
+ _postChangeTokenResult.emit(false)
+ }
+ }
+ }
+ }
+
+ private fun getUserDataFromServer() {
+ _getUserProfileState.value = UiState.Loading
+ viewModelScope.launch {
+ profileRepository.getUserData()
+ .onSuccess { profile ->
+ if (profile != null) {
+ _getUserProfileState.value = UiState.Success(profile)
+ authRepository.setYelloId(profile.yelloId)
+ }
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ _getUserProfileState.value = UiState.Failure(t.code().toString())
+ }
+ }
+ }
+ }
+
+ // 디바이스 토큰 FCM에서 받아 로컬에 저장
+ fun getDeviceToken() {
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
+ if (task.isSuccessful && task.result.isNotEmpty()) {
+ deviceToken = task.result
+ authRepository.setDeviceToken(deviceToken)
+ } else {
+ _getDeviceTokenError.value = true
+ }
+ }
+ }
+
+ fun getIsFirstLoginData() = authRepository.getIsFirstLoginData()
+
+ companion object {
+ const val KAKAO = "KAKAO"
+
+ const val THUMBNAIL = "profile_image"
+ const val EMAIL = "account_email"
+ const val FRIEND_LIST = "friends"
+ const val NAME = "name"
+ const val GENDER = "gender"
+
+ const val ERROR = "error"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/auth/SocialSyncActivity.kt b/app/src/main/java/com/el/yello/presentation/auth/SocialSyncActivity.kt
new file mode 100644
index 000000000..7de166e28
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/auth/SocialSyncActivity.kt
@@ -0,0 +1,107 @@
+package com.el.yello.presentation.auth
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import androidx.activity.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.ActivitySocialSyncBinding
+import com.el.yello.presentation.auth.SignInActivity.Companion.CHECK_NAME_DIALOG
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_EMAIL
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_GENDER
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_KAKAO_ID
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_NAME
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_PROFILE_IMAGE
+import com.el.yello.presentation.main.profile.manage.ProfileManageActivity.Companion.PRIVACY_URL
+import com.el.yello.presentation.onboarding.activity.EditNameActivity
+import com.el.yello.presentation.onboarding.fragment.checkName.CheckNameDialog
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingActivity
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@AndroidEntryPoint
+class SocialSyncActivity :
+ BindingActivity(R.layout.activity_social_sync) {
+
+ private val viewModel by viewModels()
+
+ private var checkNameDialog: CheckNameDialog? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initSocialSyncBtnListener()
+ initSocialSyncTermsListener()
+ observeFriendsAccessState()
+ }
+
+ private fun initSocialSyncBtnListener() {
+ binding.btnSocialSync.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties("click_onboarding_kakao_friends")
+ viewModel.getFriendsListFromKakao()
+ }
+ }
+
+ private fun initSocialSyncTermsListener() {
+ binding.btnSocialSyncTerms.setOnSingleClickListener {
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(PRIVACY_URL)))
+ }
+ }
+
+ private fun observeFriendsAccessState() {
+ viewModel.getFriendListState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> startCheckNameDialog()
+ is UiState.Failure -> yelloSnackbar(binding.root, getString(R.string.msg_error))
+ is UiState.Empty -> return@onEach
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun startCheckNameDialog() {
+ intent.apply {
+ val userKakaoId = getLongExtra(EXTRA_KAKAO_ID, -1)
+ val userEmail = getStringExtra(EXTRA_EMAIL)
+ val userImage = getStringExtra(EXTRA_PROFILE_IMAGE)
+ val userName = getStringExtra(EXTRA_NAME)
+ val userGender = getStringExtra(EXTRA_GENDER)
+ val bundle = Bundle().apply {
+ putLong(EXTRA_KAKAO_ID, userKakaoId)
+ putString(EXTRA_NAME, userName)
+ putString(EXTRA_GENDER, userGender)
+ putString(EXTRA_EMAIL, userEmail)
+ putString(EXTRA_PROFILE_IMAGE, userImage)
+ }
+ if (userName?.isBlank() == true || userName?.isEmpty() == true) {
+ Intent(SocialSyncActivity(), EditNameActivity::class.java).apply {
+ putExtras(bundle)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ finish()
+ } else {
+ checkNameDialog = CheckNameDialog()
+ binding.tvSocialSyncTitle.visibility = View.GONE
+ binding.tvSocialSyncSubtitle.visibility = View.GONE
+ binding.ivSocialSync.visibility = View.GONE
+ binding.btnSocialSync.visibility = View.GONE
+ checkNameDialog?.arguments = bundle
+ checkNameDialog?.show(supportFragmentManager, CHECK_NAME_DIALOG)
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ checkNameDialog?.dismiss()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/auth/SocialSyncViewModel.kt b/app/src/main/java/com/el/yello/presentation/auth/SocialSyncViewModel.kt
new file mode 100644
index 000000000..3ae9f26a3
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/auth/SocialSyncViewModel.kt
@@ -0,0 +1,28 @@
+package com.el.yello.presentation.auth
+
+import androidx.lifecycle.ViewModel
+import com.example.ui.view.UiState
+import com.kakao.sdk.talk.TalkApiClient
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+
+@HiltViewModel
+class SocialSyncViewModel @Inject constructor(
+) : ViewModel() {
+
+ private val _getFriendListState = MutableStateFlow>(UiState.Empty)
+ val getFriendListState: StateFlow> = _getFriendListState
+
+ fun getFriendsListFromKakao() {
+ TalkApiClient.instance.friends { _, error ->
+ _getFriendListState.value = UiState.Loading
+ if (error == null) {
+ _getFriendListState.value = UiState.Success(Unit)
+ } else {
+ _getFriendListState.value = UiState.Failure(error.message.toString())
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/MainActivity.kt b/app/src/main/java/com/el/yello/presentation/main/MainActivity.kt
new file mode 100644
index 000000000..4535f586c
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/MainActivity.kt
@@ -0,0 +1,288 @@
+package com.el.yello.presentation.main
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.viewModels
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.commit
+import androidx.fragment.app.replace
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.ActivityMainBinding
+import com.el.yello.presentation.main.dialog.notice.NoticeDialog
+import com.el.yello.presentation.main.look.LookFragment
+import com.el.yello.presentation.main.myyello.MyYelloFragment
+import com.el.yello.presentation.main.myyello.read.MyYelloReadActivity
+import com.el.yello.presentation.main.profile.info.ProfileFragment
+import com.el.yello.presentation.main.recommend.RecommendFragment
+import com.el.yello.presentation.main.yello.YelloFragment
+import com.el.yello.presentation.pay.PayReSubsNoticeDialog
+import com.el.yello.presentation.util.dp
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.domain.enum.SubscribeType.CANCELED
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.toast
+import com.example.ui.intent.stringExtra
+import com.example.ui.view.UiState.Empty
+import com.example.ui.view.UiState.Failure
+import com.example.ui.view.UiState.Loading
+import com.example.ui.view.UiState.Success
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.concurrent.TimeUnit
+
+@AndroidEntryPoint
+class MainActivity : BindingActivity(R.layout.activity_main) {
+ private val viewModel by viewModels()
+
+ private val path by stringExtra()
+ private val type by stringExtra()
+
+ private var backPressedTime: Long = 0
+
+ private val onBackPressedCallback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (System.currentTimeMillis() - backPressedTime >= BACK_PRESSED_INTERVAL) {
+ backPressedTime = System.currentTimeMillis()
+ toast(getString(R.string.main_toast_back_pressed))
+ } else {
+ finish()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ initBackPressedCallback()
+ initBnvItemIconTintList()
+ initBnvItemSelectedListener()
+ initBnvItemReselectedListener()
+ initPushNotificationEvent()
+ setupGetUserSubsState()
+ setupGetNoticeState()
+ setupGetVoteCountState()
+ }
+
+ private fun initBackPressedCallback() {
+ onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
+ }
+
+ private fun initBnvItemIconTintList() {
+ binding.bnvMain.itemIconTintList = null
+ binding.bnvMain.selectedItemId = R.id.menu_yello
+ }
+
+ private fun initBnvItemSelectedListener() {
+ supportFragmentManager.findFragmentById(R.id.fcv_main) ?: navigateTo()
+
+ binding.bnvMain.setOnItemSelectedListener { menu ->
+ when (menu.itemId) {
+ R.id.menu_recommend -> {
+ AmplitudeUtils.trackEventWithProperties(EVENT_CLICK_RECOMMEND_NAVIGATION)
+ navigateTo()
+ }
+
+ R.id.menu_look -> navigateTo()
+ R.id.menu_yello -> {
+ navigateTo()
+ binding.btnMainYelloActive.visibility = View.VISIBLE
+ return@setOnItemSelectedListener true
+ }
+
+ R.id.menu_my_yello -> navigateTo()
+ R.id.menu_profile -> navigateTo()
+ }
+ binding.btnMainYelloActive.visibility = View.INVISIBLE
+ true
+ }
+ }
+
+ private fun initBnvItemReselectedListener() {
+ binding.bnvMain.setOnItemReselectedListener { menu ->
+ val currentFragment = supportFragmentManager.findFragmentById(R.id.fcv_main)
+ when (menu.itemId) {
+ R.id.menu_recommend -> {
+ if (currentFragment is RecommendFragment) {
+ currentFragment.scrollToTop()
+ }
+ }
+
+ R.id.menu_look -> {
+ if (currentFragment is LookFragment) {
+ currentFragment.scrollToTop()
+ }
+ }
+
+ R.id.menu_my_yello -> {
+ if (currentFragment is MyYelloFragment) {
+ currentFragment.scrollToTop()
+ }
+ }
+
+ R.id.menu_profile -> {
+ if (currentFragment is ProfileFragment) {
+ currentFragment.scrollToTop()
+ }
+ }
+ }
+ }
+ }
+
+ private fun initBadge(voteCount: Int) {
+ val badgeDrawable = binding.bnvMain.getOrCreateBadge(R.id.menu_my_yello)
+ badgeDrawable.verticalOffset = 12.dp
+ badgeDrawable.horizontalOffset = 10.dp
+ badgeDrawable.number = voteCount
+ badgeDrawable.backgroundColor = ContextCompat.getColor(
+ this,
+ R.color.semantic_red_500,
+ )
+ badgeDrawable.badgeTextColor = ContextCompat.getColor(
+ this,
+ R.color.white,
+ )
+ }
+
+ private fun initPushNotificationEvent() {
+ when (type) {
+ PUSH_TYPE_NEW_VOTE -> {
+ binding.bnvMain.menu.getItem(3).isChecked = true
+ navigateTo()
+
+ path?.let {
+ val questionId = it.split("/").lastOrNull()?.toLong()
+ questionId?.let {
+ startActivity(MyYelloReadActivity.getIntent(this, questionId))
+ }
+ }
+ }
+
+ PUSH_TYPE_NEW_FRIEND -> {
+ binding.bnvMain.menu.getItem(4).isChecked = true
+ navigateTo()
+ }
+
+ PUSH_TYPE_VOTE_AVAILABLE, PUSH_TYPE_RECOMMEND -> {
+ binding.bnvMain.menu.getItem(2).isChecked = true
+ navigateTo()
+ }
+ }
+ }
+
+ private inline fun navigateTo() {
+ supportFragmentManager.commit {
+ replace(R.id.fcv_main, T::class.java.canonicalName)
+ }
+ }
+
+ private fun setupGetUserSubsState() {
+ viewModel.getUserSubsState.flowWithLifecycle(lifecycle)
+ .onEach { state ->
+ when (state) {
+ is Empty -> return@onEach
+ is Loading -> return@onEach
+ is Success -> {
+ if (state.data?.subscribe != CANCELED) return@onEach
+ // TODO : 도메인 모델에 변환된 날짜로 파싱되도록 보완
+ val expiredDateString = state.data?.expiredDate.toString()
+ val expiredDate =
+ SimpleDateFormat(EXPIRED_DATE_FORMAT).parse(expiredDateString)
+ ?: return@onEach
+ val currentDate = Calendar.getInstance().time
+ val daysDifference = TimeUnit.DAYS.convert(
+ expiredDate.time - currentDate.time,
+ TimeUnit.MILLISECONDS,
+ )
+ if (daysDifference >= 1) {
+ PayReSubsNoticeDialog.newInstance(expiredDateString)
+ .show(supportFragmentManager, PAY_RESUBS_DIALOG)
+ }
+ }
+
+ is Failure -> yelloSnackbar(binding.root, getString(R.string.msg_error))
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setupGetNoticeState() {
+ viewModel.getNoticeState.flowWithLifecycle(lifecycle)
+ .onEach { state ->
+ when (state) {
+ is Empty -> yelloSnackbar(binding.root, getString(R.string.msg_error))
+ is Loading -> return@onEach
+ is Success -> {
+ if (!state.data.isAvailable) return@onEach
+ NoticeDialog.newInstance(
+ imageUrl = state.data.imageUrl,
+ redirectUrl = state.data.redirectUrl,
+ ).show(supportFragmentManager, TAG_NOTICE_DIALOG)
+ }
+
+ is Failure -> yelloSnackbar(
+ binding.root,
+ getString(R.string.main_get_notice_failure),
+ )
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setupGetVoteCountState() {
+ viewModel.voteCount.flowWithLifecycle(lifecycle)
+ .onEach { state ->
+ when (state) {
+ is Empty -> return@onEach
+
+ is Loading -> return@onEach
+
+ is Success -> {
+ if (state.data.totalCount != 0) {
+ initBadge(state.data.totalCount)
+ }
+ }
+
+ is Failure -> {
+ yelloSnackbar(binding.root, state.msg)
+ }
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ fun setBadgeCount(count: Int) {
+ val badgeDrawable = binding.bnvMain.getOrCreateBadge(R.id.menu_my_yello)
+ badgeDrawable.number = count
+ badgeDrawable.isVisible = count != 0
+ }
+
+ companion object {
+ private const val EXTRA_TYPE = "type"
+ private const val EXTRA_PATH = "path"
+
+ const val PUSH_TYPE_NEW_VOTE = "NEW_VOTE"
+ const val PUSH_TYPE_NEW_FRIEND = "NEW_FRIEND"
+ const val PUSH_TYPE_VOTE_AVAILABLE = "VOTE_AVAILABLE"
+ const val PUSH_TYPE_RECOMMEND = "RECOMMEND"
+
+ const val BACK_PRESSED_INTERVAL = 2000
+ const val EXPIRED_DATE_FORMAT = "yyyy-MM-dd"
+ const val PAY_RESUBS_DIALOG = "PayResubsNoticeDialog"
+ private const val EVENT_CLICK_RECOMMEND_NAVIGATION = "click_recommend_navigation"
+
+ private const val TAG_NOTICE_DIALOG = "NOTICE_DIALOG"
+
+ @JvmStatic
+ fun getIntent(context: Context, type: String? = null, path: String? = null) =
+ Intent(context, MainActivity::class.java).apply {
+ putExtra(EXTRA_TYPE, type)
+ putExtra(EXTRA_PATH, path)
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/MainViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/MainViewModel.kt
new file mode 100644
index 000000000..d42ff8a7b
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/MainViewModel.kt
@@ -0,0 +1,120 @@
+package com.el.yello.presentation.main
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.domain.entity.PayUserSubsInfoModel
+import com.example.domain.entity.notice.Notice
+import com.example.domain.entity.vote.VoteCount
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.NoticeRepository
+import com.example.domain.repository.PayRepository
+import com.example.domain.repository.YelloRepository
+import com.example.ui.view.UiState
+import com.google.firebase.messaging.FirebaseMessaging
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+ private val payRepository: PayRepository,
+ private val noticeRepository: NoticeRepository,
+ private val yelloRepository: YelloRepository,
+) : ViewModel() {
+ private val _getUserSubsState =
+ MutableStateFlow>(UiState.Loading)
+ val getUserSubsState: StateFlow>
+ get() = _getUserSubsState
+
+ private val _getNoticeState = MutableStateFlow>(UiState.Loading)
+ val getNoticeState: StateFlow>
+ get() = _getNoticeState
+
+ private val _voteCount = MutableStateFlow>(UiState.Loading)
+ val voteCount: StateFlow>
+ get() = _voteCount
+
+ init {
+ putDeviceToken()
+ getUserSubscriptionState()
+ getNotice()
+ getVoteCount()
+ authRepository.setIsFirstLoginData()
+ }
+
+ private fun putDeviceToken() {
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { addTask ->
+ runCatching {
+ addTask.result
+ }.onSuccess { token ->
+ if (authRepository.getDeviceToken() != token) resetDeviceToken(token)
+ }
+ }
+ }
+
+ private fun resetDeviceToken(token: String) {
+ authRepository.setDeviceToken(token)
+ viewModelScope.launch {
+ runCatching {
+ authRepository.putDeviceToken(token)
+ }.onFailure(Timber::e)
+ }
+ }
+
+ private fun getUserSubscriptionState() {
+ viewModelScope.launch {
+ payRepository.getUserSubsInfo()
+ .onSuccess { userInfo ->
+ if (userInfo == null) {
+ _getUserSubsState.value = UiState.Empty
+ } else {
+ _getUserSubsState.value = UiState.Success(userInfo)
+ }
+ }
+ .onFailure {
+ _getUserSubsState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ private fun getNotice() {
+ viewModelScope.launch {
+ noticeRepository.getNotice()
+ .onSuccess { notice ->
+ if (notice == null) {
+ _getNoticeState.value = UiState.Empty
+ return@onSuccess
+ }
+
+ if (noticeRepository.isDisabledNoticeUrl(notice.imageUrl)) return@onSuccess
+ _getNoticeState.value = UiState.Success(notice)
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ _getNoticeState.value = UiState.Failure(t.code().toString())
+ return@onFailure
+ }
+ _getNoticeState.value = UiState.Failure(t.message.toString())
+ }
+ }
+ }
+
+ private fun getVoteCount() {
+ viewModelScope.launch {
+ yelloRepository.voteCount()
+ .onSuccess {
+ if (it != null) {
+ _voteCount.value = UiState.Success(it)
+ }
+ }
+ .onFailure {
+ _voteCount.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/dialog/invite/InviteFriendDialog.kt b/app/src/main/java/com/el/yello/presentation/main/dialog/invite/InviteFriendDialog.kt
new file mode 100644
index 000000000..1c481cc62
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/dialog/invite/InviteFriendDialog.kt
@@ -0,0 +1,170 @@
+package com.el.yello.presentation.main.dialog.invite
+
+import android.content.ActivityNotFoundException
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Build
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import androidx.core.os.bundleOf
+import com.el.yello.BuildConfig
+import com.el.yello.R
+import com.el.yello.databinding.FragmentInviteFriendDialogBinding
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.setOnSingleClickListener
+import com.kakao.sdk.common.util.KakaoCustomTabsClient
+import com.kakao.sdk.share.ShareClient
+import com.kakao.sdk.share.WebSharerClient
+import org.json.JSONObject
+import timber.log.Timber
+
+class InviteFriendDialog :
+ BindingDialogFragment(R.layout.fragment_invite_friend_dialog) {
+
+ private lateinit var myYelloId: String
+ private lateinit var previousScreen: String
+ private lateinit var linkText: String
+ private var templateId: Long = 0
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.apply {
+ setLayout(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.color.transparent)
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ getBundleArgs()
+ setTemplateId()
+ setRecommendId()
+ initExitBtnListener()
+ initKakaoInviteBtnListener()
+ initLinkInviteBtnListener()
+ }
+
+ private fun getBundleArgs() {
+ arguments ?: return
+ myYelloId = arguments?.getString(ARGS_YELLO_ID) ?: ""
+ previousScreen = arguments?.getString(ARGS_PREVIOUS_SCREEN) ?: ""
+ linkText = LINK_TEXT.format(myYelloId)
+ }
+
+ private fun setRecommendId() {
+ binding.tvRecommendDialogInviteId.text = myYelloId
+ }
+
+ private fun setTemplateId() {
+ templateId = if (BuildConfig.DEBUG) {
+ TEST_TEMPLATE_ID.toLong()
+ } else {
+ TEMPLATE_ID.toLong()
+ }
+ }
+
+ private fun initExitBtnListener() {
+ binding.btnInviteDialogExit.setOnSingleClickListener {
+ dismiss()
+ }
+ }
+
+ private fun initKakaoInviteBtnListener() {
+ binding.btnInviteKakao.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_invite_kakao",
+ JSONObject().put("invite_view", previousScreen),
+ )
+ startKakaoInvite(requireContext())
+ }
+ }
+
+ private fun initLinkInviteBtnListener() {
+ binding.btnInviteLink.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_invite_link",
+ JSONObject().put("invite_view", previousScreen),
+ )
+ val clipboardManager =
+ requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clipData = ClipData.newPlainText(CLIP_LABEL, linkText)
+ clipboardManager.setPrimaryClip(clipData)
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) toast(getString(R.string.invite_clipboard_msg))
+ }
+ }
+
+ private fun startKakaoInvite(context: Context) {
+ if (ShareClient.instance.isKakaoTalkSharingAvailable(context)) {
+ shareKakaoWithApp(context)
+ } else {
+ shareKakaoWithWeb(context)
+ }
+ }
+
+ private fun shareKakaoWithApp(context: Context) {
+ ShareClient.instance.shareCustom(
+ context,
+ templateId,
+ mapOf(KEY_YELLO_ID to myYelloId),
+ ) { sharingResult, error ->
+ if (error != null) {
+ Timber.tag(TAG_SHARE).e(error, getString(R.string.invite_error_kakao))
+ } else if (sharingResult != null) {
+ startActivity(sharingResult.intent)
+ }
+ }
+ }
+
+ private fun shareKakaoWithWeb(context: Context) {
+ val sharerUrl = WebSharerClient.instance.makeCustomUrl(templateId)
+ // 1. CustomTabsServiceConnection 지원 브라우저 - Chrome, 삼성 인터넷 등
+ try {
+ KakaoCustomTabsClient.openWithDefault(context, sharerUrl)
+ return
+ } catch (error: UnsupportedOperationException) {
+ Timber.tag(TAG_SHARE).e(error, getString(R.string.invite_error_browser))
+ }
+ // 2. CustomTabsServiceConnection 미지원 브라우저 - 네이버 앱 등
+ try {
+ KakaoCustomTabsClient.open(context, sharerUrl)
+ return
+ } catch (error: ActivityNotFoundException) {
+ Timber.tag(TAG_SHARE).e(error, getString(R.string.invite_error_browser))
+ }
+ }
+
+ companion object {
+ const val TAG_SHARE = "recommendInvite"
+
+ const val ARGS_PREVIOUS_SCREEN = "PREVIOUS_SCREEN"
+ const val ARGS_YELLO_ID = "YELLO_ID"
+
+ const val TEMPLATE_ID = 95890
+ const val TEST_TEMPLATE_ID = 96906
+
+ const val LINK_TEXT = "추천인코드: %s\n" +
+ "우리 같이 YELL:O 해요!\n" +
+ "Android: https://play.google.com/store/apps/details?id=com.el.yello&hl=ko&gl=KR\n" +
+ "iOS: https://apps.apple.com/app/id6451451050"
+
+ const val CLIP_LABEL = "RECOMMEND_LINK"
+ private const val KEY_YELLO_ID = "KEY"
+
+ @JvmStatic
+ fun newInstance(yelloId: String, previousScreen: String) = InviteFriendDialog().apply {
+ val args = bundleOf(
+ ARGS_YELLO_ID to yelloId,
+ ARGS_PREVIOUS_SCREEN to previousScreen,
+ )
+ arguments = args
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/dialog/notice/NoticeDialog.kt b/app/src/main/java/com/el/yello/presentation/main/dialog/notice/NoticeDialog.kt
new file mode 100644
index 000000000..d6971a6c5
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/dialog/notice/NoticeDialog.kt
@@ -0,0 +1,113 @@
+package com.el.yello.presentation.main.dialog.notice
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import androidx.core.os.bundleOf
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import coil.load
+import com.el.yello.R
+import com.el.yello.databinding.FragmentNoticeDialogBinding
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@AndroidEntryPoint
+class NoticeDialog :
+ BindingDialogFragment(R.layout.fragment_notice_dialog) {
+ private val viewModel by viewModels()
+
+ private lateinit var imageUrl: String
+ private lateinit var redirectUrl: String
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.apply {
+ setLayout(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.color.transparent)
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ getBundleArgs()
+ initNoticeImageView()
+ initDoNotSeeItAgainBtnClickListener()
+ initCloseBtnClickListener()
+ setupIsNoticeDisabled()
+ }
+
+ private fun getBundleArgs() {
+ imageUrl = arguments?.getString(ARGS_IMAGE_URL) ?: return
+ redirectUrl = arguments?.getString(ARGS_REDIRECT_URL) ?: return
+ }
+
+ private fun initNoticeImageView() {
+ with(binding.ivNoticeImg) {
+ load(imageUrl)
+
+ if (redirectUrl.isBlank()) return
+ setOnSingleClickListener {
+ Intent(Intent.ACTION_VIEW, Uri.parse(redirectUrl))
+ }
+ }
+ }
+
+ private fun initDoNotSeeItAgainBtnClickListener() {
+ binding.btnNoticeDoNotSeeItAgain.setOnSingleClickListener {
+ viewModel.switchNoticeDisabledState()
+ }
+ binding.icNoticeDoNotSeeItAgain.setOnSingleClickListener {
+ viewModel.switchNoticeDisabledState()
+ }
+ binding.tvNoticeDoNotSeeItAgain.setOnSingleClickListener {
+ viewModel.switchNoticeDisabledState()
+ }
+ }
+
+ private fun setupIsNoticeDisabled() {
+ viewModel.isNoticeDisabled.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach { isNoticeDisabled ->
+ binding.icNoticeDoNotSeeItAgain.setImageResource(
+ if (isNoticeDisabled) R.drawable.ic_notice_check else R.drawable.ic_notice_uncheck,
+ )
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private fun initCloseBtnClickListener() {
+ binding.tvNoticeClose.setOnSingleClickListener {
+ dismiss()
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ viewModel.setDisabledNotice(imageUrl)
+ }
+
+ companion object {
+ private const val ARGS_IMAGE_URL = "IMAGE_URL"
+ private const val ARGS_REDIRECT_URL = "REDIRECT_URL"
+
+ @JvmStatic
+ fun newInstance(
+ imageUrl: String,
+ redirectUrl: String,
+ ) = NoticeDialog().apply {
+ arguments = bundleOf(
+ ARGS_IMAGE_URL to imageUrl,
+ ARGS_REDIRECT_URL to redirectUrl,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/dialog/notice/NoticeViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/dialog/notice/NoticeViewModel.kt
new file mode 100644
index 000000000..f71f09999
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/dialog/notice/NoticeViewModel.kt
@@ -0,0 +1,24 @@
+package com.el.yello.presentation.main.dialog.notice
+
+import androidx.lifecycle.ViewModel
+import com.example.domain.repository.NoticeRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import javax.inject.Inject
+
+@HiltViewModel
+class NoticeViewModel @Inject constructor(
+ private val noticeRepository: NoticeRepository,
+) : ViewModel() {
+ private val _isNoticeDisabled = MutableStateFlow(false)
+ val isNoticeDisabled
+ get() = _isNoticeDisabled
+
+ fun switchNoticeDisabledState() {
+ _isNoticeDisabled.value = !isNoticeDisabled.value
+ }
+
+ fun setDisabledNotice(url: String) {
+ if (isNoticeDisabled.value) noticeRepository.setDisabledNoticeUrl(url)
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/look/LookFragment.kt b/app/src/main/java/com/el/yello/presentation/main/look/LookFragment.kt
new file mode 100644
index 000000000..b59eed3ce
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/look/LookFragment.kt
@@ -0,0 +1,170 @@
+package com.el.yello.presentation.main.look
+
+import android.os.Bundle
+import android.view.View
+import android.view.animation.AnimationUtils
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.paging.LoadState
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.FragmentLookBinding
+import com.el.yello.presentation.main.dialog.invite.InviteFriendDialog
+import com.el.yello.presentation.util.BaseLinearRcvItemDeco
+import com.el.yello.util.Utils.setPullToScrollColor
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class LookFragment : BindingFragment(R.layout.fragment_look) {
+
+ private var _adapter: LookPageAdapter? = null
+ private val adapter
+ get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ private val viewModel by viewModels()
+
+ private var inviteFriendDialog: InviteFriendDialog? = null
+
+ private var isScrolled: Boolean = false
+ private var isNoFriend: Boolean = false
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ initInviteBtnListener()
+ setListBottomPadding()
+ observeTimelinePagingList()
+ setPullToScrollListener()
+ observePagingLoadingState()
+ catchScrollForAmplitude()
+ AmplitudeUtils.trackEventWithProperties("view_timeline")
+ }
+
+ private fun initAdapter() {
+ viewModel.setFirstLoading(true)
+ _adapter = LookPageAdapter()
+ adapter.addLoadStateListener { combinedLoadStates ->
+ if (combinedLoadStates.prepend.endOfPaginationReached) {
+ binding.layoutLookNoFriendsList.isVisible = adapter.itemCount < 1
+ binding.rvLook.isGone = adapter.itemCount < 1
+ isNoFriend = adapter.itemCount < 1
+ }
+ }
+ binding.rvLook.adapter = adapter
+ }
+
+ private fun initInviteBtnListener() {
+ binding.btnLookNoFriend.setOnSingleClickListener {
+ inviteFriendDialog =
+ InviteFriendDialog.newInstance(viewModel.getYelloId(), TIMELINE_NO_FRIEND)
+ AmplitudeUtils.trackEventWithProperties(
+ "click_invite", JSONObject().put("invite_view", TIMELINE_NO_FRIEND)
+ )
+ inviteFriendDialog?.show(parentFragmentManager, INVITE_DIALOG)
+ }
+ }
+
+ private fun setListBottomPadding() {
+ binding.rvLook.addItemDecoration(BaseLinearRcvItemDeco(bottomPadding = 14))
+ }
+
+ private fun setPullToScrollListener() {
+ binding.layoutLookSwipe.apply {
+ setOnRefreshListener {
+ adapter.refresh()
+ viewModel.setFirstLoading(true)
+ }
+ setPullToScrollColor(R.color.grayscales_500, R.color.grayscales_700)
+ }
+ adapter.loadStateFlow.flowWithLifecycle(lifecycle)
+ .distinctUntilChangedBy { it.refresh }.onEach {
+ delay(200)
+ binding.layoutLookSwipe.isRefreshing = false
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeTimelinePagingList() {
+ viewModel.getLookListWithPaging().flowWithLifecycle(lifecycle)
+ .onEach { pagingData ->
+ adapter.submitData(lifecycle, pagingData)
+ }.launchIn(lifecycleScope)
+ }
+
+
+ private fun observePagingLoadingState() {
+ adapter.loadStateFlow.flowWithLifecycle(lifecycle)
+ .onEach { loadStates ->
+ when (loadStates.refresh) {
+ is LoadState.Loading -> {
+ if (!isNoFriend) showShimmerView(true)
+ }
+
+ is LoadState.NotLoading -> {
+ if (viewModel.isFirstLoading.value) {
+ startFadeIn()
+ viewModel.setFirstLoading(false)
+ }
+ showShimmerView(false)
+ }
+
+ is LoadState.Error -> {
+ showShimmerView(true)
+ yelloSnackbar(requireView(), getString(R.string.look_error_friend_list))
+ }
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun catchScrollForAmplitude() {
+ binding.rvLook.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+ super.onScrollStateChanged(recyclerView, newState)
+ if (newState == RecyclerView.SCROLL_STATE_IDLE && !isScrolled) {
+ AmplitudeUtils.trackEventWithProperties("scroll_profile_friends")
+ isScrolled = true
+ }
+ }
+ })
+ }
+
+ private fun startFadeIn() {
+ val animation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in)
+ binding.rvLook.startAnimation(animation)
+ }
+
+ private fun showShimmerView(isShown: Boolean) {
+ with(binding) {
+ if (isShown) shimmerLookList.startShimmer() else shimmerLookList.stopShimmer()
+ shimmerLookList.isVisible = isShown
+ rvLook.isVisible = !isShown
+ }
+ }
+
+ fun scrollToTop() {
+ binding.rvLook.smoothScrollToPosition(0)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _adapter = null
+ if (inviteFriendDialog != null) inviteFriendDialog?.dismiss()
+ }
+
+ companion object {
+ const val INVITE_DIALOG = "inviteDialog"
+ const val TIMELINE_NO_FRIEND = "timeline_0friend"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/look/LookPageAdapter.kt b/app/src/main/java/com/el/yello/presentation/main/look/LookPageAdapter.kt
new file mode 100644
index 000000000..f37bdc80d
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/look/LookPageAdapter.kt
@@ -0,0 +1,32 @@
+package com.el.yello.presentation.main.look
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.paging.PagingDataAdapter
+import com.el.yello.databinding.ItemLookBinding
+import com.example.domain.entity.LookListModel.LookModel
+import com.example.ui.view.ItemDiffCallback
+
+class LookPageAdapter : PagingDataAdapter(diffUtil) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LookViewHolder {
+ val inflater by lazy { LayoutInflater.from(parent.context) }
+ val binding: ItemLookBinding = ItemLookBinding.inflate(inflater, parent, false)
+ return LookViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: LookViewHolder, position: Int) {
+ holder.binding.tvNameHead.visibility = View.VISIBLE
+ holder.binding.tvKeywordHead.visibility = View.VISIBLE
+ val item = getItem(position) ?: return
+ holder.onBind(item)
+ }
+
+ companion object {
+ private val diffUtil = ItemDiffCallback(
+ onItemsTheSame = { old, new -> old.id == new.id },
+ onContentsTheSame = { old, new -> old == new },
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/look/LookViewHolder.kt b/app/src/main/java/com/el/yello/presentation/main/look/LookViewHolder.kt
new file mode 100644
index 000000000..b9ac20783
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/look/LookViewHolder.kt
@@ -0,0 +1,62 @@
+package com.el.yello.presentation.main.look
+
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.ItemLookBinding
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.domain.entity.LookListModel.LookModel
+import com.example.ui.context.colorOf
+import com.example.ui.context.drawableOf
+
+class LookViewHolder(
+ val binding: ItemLookBinding
+) : RecyclerView.ViewHolder(binding.root) {
+
+ fun onBind(item: LookModel) {
+ with(binding) {
+ tvLookName.text = item.receiverName
+ tvLookTime.text = item.createdAt
+ tvNameHead.text = item.vote.nameHead
+ tvNameFoot.text = item.vote.nameFoot
+ tvKeywordHead.text = item.vote.keywordHead
+ tvKeywordFoot.text = item.vote.keywordFoot
+
+ tvNameHead.isVisible = !item.vote.nameHead.isNullOrEmpty()
+ tvKeywordHead.isVisible = !item.vote.keywordHead.isNullOrEmpty()
+
+ ivLookThumbnail.setImageOrBasicThumbnail(item.receiverProfileImage)
+
+ tvKeyword.apply {
+ text = item.vote.keyword
+ background =
+ if (item.isHintUsed) null else itemView.context.drawableOf(R.drawable.shape_grayscales800_fill_grayscales700_dashline_4_rect)
+ setTextColor(
+ itemView.context.colorOf(
+ when {
+ item.isHintUsed && item.senderGender == MALE -> R.color.semantic_gender_m_300
+ item.isHintUsed && item.senderGender != MALE -> R.color.semantic_gender_f_300
+ else -> R.color.grayscales_800
+ }
+ )
+ )
+ }
+
+ tvLookSendGender.apply {
+ text = if (item.senderGender == MALE) {
+ setTextColor(itemView.context.colorOf(R.color.semantic_gender_m_500))
+ FROM_MALE
+ } else {
+ setTextColor(itemView.context.colorOf(R.color.semantic_gender_f_500))
+ FROM_FEMALE
+ }
+ }
+ }
+ }
+
+ private companion object {
+ const val MALE = "MALE"
+ const val FROM_MALE = "남학생에게 받음"
+ const val FROM_FEMALE = "여학생에게 받음"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/look/LookViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/look/LookViewModel.kt
new file mode 100644
index 000000000..5aea27b1e
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/look/LookViewModel.kt
@@ -0,0 +1,39 @@
+package com.el.yello.presentation.main.look
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import com.example.data.datasource.paging.LookPagingSource
+import com.example.data.remote.service.LookService
+import com.example.domain.entity.LookListModel.LookModel
+import com.example.domain.repository.AuthRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import javax.inject.Inject
+
+@HiltViewModel
+class LookViewModel @Inject constructor(
+ private val lookService: LookService,
+ private val authRepository: AuthRepository
+) : ViewModel() {
+
+ private val _isFirstLoading = MutableStateFlow(true)
+ val isFirstLoading: StateFlow = _isFirstLoading
+
+ fun setFirstLoading(boolean: Boolean) {
+ _isFirstLoading.value = boolean
+ }
+
+ fun getLookListWithPaging(): Flow> =
+ Pager(
+ config = PagingConfig(LookPagingSource.LOOK_PAGE_SIZE),
+ pagingSourceFactory = { LookPagingSource(lookService) }
+ ).flow.cachedIn(viewModelScope)
+
+ fun getYelloId() = authRepository.getYelloId()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloAdapter.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloAdapter.kt
new file mode 100644
index 000000000..dc9969418
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloAdapter.kt
@@ -0,0 +1,141 @@
+package com.el.yello.presentation.main.myyello
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.ItemMyYelloBinding
+import com.el.yello.util.Utils
+import com.example.domain.entity.Yello
+import com.example.domain.enum.Gender
+import com.example.ui.view.setOnSingleClickListener
+
+class MyYelloAdapter(private val itemClick: (Yello, Int) -> (Unit)) :
+ RecyclerView.Adapter() {
+ private val yelloList = mutableListOf()
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyYelloViewHolder {
+ val binding = ItemMyYelloBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ return MyYelloViewHolder(binding, itemClick)
+ }
+
+ override fun onBindViewHolder(holder: MyYelloViewHolder, position: Int) {
+ holder.onBind(yelloList[position], position)
+ }
+
+ override fun getItemCount(): Int = yelloList.size
+
+ override fun getItemId(position: Int): Long {
+ return position.toLong()
+ }
+
+ fun addItem(newItems: List) {
+ yelloList.addAll(newItems)
+ notifyDataSetChanged()
+ }
+
+ fun clearList() {
+ yelloList.clear()
+ notifyDataSetChanged()
+ }
+
+ fun currentList(): List {
+ return yelloList
+ }
+
+ fun changeItem(position: Int, newItem: Yello) {
+ yelloList[position] = newItem
+ notifyItemChanged(position)
+ }
+
+ class MyYelloViewHolder(
+ private val binding: ItemMyYelloBinding,
+ private val itemClick: (Yello, Int) -> Unit
+ ) : RecyclerView.ViewHolder(binding.root) {
+ fun onBind(item: Yello, position: Int) {
+ binding.data = item
+ binding.ivReadYelloPoint.isVisible = !item.isRead
+ binding.tvTime.text = item.createdAt
+ binding.clSendCheck.isVisible = (item.isHintUsed || item.nameHint != -1) && item.isRead
+ binding.clMiddle.isGone = !item.isHintUsed && (item.nameHint == -2 || item.nameHint == -3) && item.isRead
+ binding.clBottom.isGone = !item.isHintUsed && (item.nameHint == -2 || item.nameHint == -3) && item.isRead
+ binding.tvGender.isVisible = (!item.isHintUsed && item.nameHint == -1) || !item.isRead
+ binding.cardMyYello.setCardBackgroundColor(
+ ContextCompat.getColor(
+ itemView.context,
+ R.color.grayscales_900
+ )
+ )
+ binding.tvTime.setTextColor(ContextCompat.getColor(itemView.context, R.color.grayscales_600))
+ if (item.gender == Gender.M) {
+ if ((item.isHintUsed || item.nameHint != -1) && item.isRead) {
+ binding.cardMyYello.setCardBackgroundColor(
+ ContextCompat.getColor(
+ itemView.context,
+ R.color.semantic_gender_m_700
+ )
+ )
+ ContextCompat.getColor(itemView.context, R.color.semantic_gender_m_300).apply {
+ binding.tvSendName.setTextColor(this)
+ binding.tvSendNameEnd.setTextColor(this)
+ }
+ binding.tvTime.setTextColor(ContextCompat.getColor(itemView.context, R.color.semantic_gender_m_500))
+ }
+ binding.ivYello.setImageDrawable(
+ ContextCompat.getDrawable(
+ itemView.context,
+ R.drawable.ic_yello_blue
+ )
+ )
+ binding.tvGender.text = "남학생이 보냄"
+ } else {
+ if ((item.isHintUsed || item.nameHint != -1) && item.isRead) {
+ binding.cardMyYello.setCardBackgroundColor(
+ ContextCompat.getColor(
+ itemView.context,
+ R.color.semantic_gender_f_700
+ )
+ )
+ ContextCompat.getColor(itemView.context, R.color.semantic_gender_f_300).apply {
+ binding.tvSendName.setTextColor(this)
+ binding.tvSendNameEnd.setTextColor(this)
+ }
+ binding.tvTime.setTextColor(ContextCompat.getColor(itemView.context, R.color.semantic_gender_f_500))
+ }
+ binding.ivYello.setImageDrawable(
+ ContextCompat.getDrawable(
+ itemView.context,
+ R.drawable.ic_yello_pink
+ )
+ )
+ binding.tvGender.text = "여학생이 보냄"
+ }
+
+ if (item.isHintUsed) {
+ binding.tvNameHead.text = item.vote.nameHead
+ binding.tvNameFoot.text = item.vote.nameFoot
+ binding.tvKeywordHead.text = item.vote.keywordHead
+ binding.tvKeyword.text = item.vote.keyword
+ binding.tvKeywordFoot.text = item.vote.keywordFoot
+ if (item.nameHint >= 0) {
+ binding.tvSendName.text = Utils.setChosungText(item.senderName, item.nameHint)
+ }
+ binding.clSendName.isVisible = item.nameHint != -1
+ }
+ if(item.nameHint == -2 || item.nameHint == -3) {
+ binding.tvSendName.text = item.senderName
+ }
+
+ binding.root.setOnSingleClickListener {
+ itemClick(item, position)
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloFragment.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloFragment.kt
new file mode 100644
index 000000000..8c85395f6
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloFragment.kt
@@ -0,0 +1,237 @@
+package com.el.yello.presentation.main.myyello
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.view.animation.AnimationUtils
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.isVisible
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.FragmentMyYelloBinding
+import com.el.yello.presentation.main.MainActivity
+import com.el.yello.presentation.main.myyello.read.MyYelloReadActivity
+import com.el.yello.presentation.pay.PayActivity
+import com.el.yello.presentation.util.BaseLinearRcvItemDeco
+import com.el.yello.util.Utils.setPullToScrollColor
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class MyYelloFragment : BindingFragment(R.layout.fragment_my_yello) {
+
+ private val viewModel by viewModels()
+ private var adapter: MyYelloAdapter? = null
+ private var isScrolled: Boolean = false
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ AmplitudeUtils.trackEventWithProperties("view_all_messages")
+ initView()
+ initEvent()
+ observe()
+ initPullToScrollListener()
+ }
+
+ private fun initView() {
+ viewModel.getMyYelloList()
+ adapter = MyYelloAdapter { it, pos ->
+ viewModel.setPosition(pos)
+ myYelloReadActivityLauncher.launch(
+ MyYelloReadActivity.getIntent(
+ requireContext(),
+ it.id,
+ it.nameHint,
+ it.isHintUsed,
+ ),
+ )
+ }
+ binding.rvMyYelloReceive.addItemDecoration(
+ BaseLinearRcvItemDeco(8, 8, 0, 0, 5, RecyclerView.VERTICAL, 110)
+ )
+ adapter?.setHasStableIds(true)
+ binding.rvMyYelloReceive.adapter = adapter
+
+ infinityScroll()
+ }
+
+ private fun initEvent() {
+ binding.btnSendCheck.setOnSingleClickListener {
+ setClickGoShopAmplitude("cta_main")
+ Intent(requireContext(), PayActivity::class.java).apply {
+ payActivityLauncher.launch(this)
+ }
+ }
+
+ binding.clSendOpen.setOnSingleClickListener {
+ yelloSnackbar(binding.root, "무슨 쪽지가 궁금한가요?")
+ }
+
+ binding.btnShop.setOnSingleClickListener {
+ setClickGoShopAmplitude("message_shop")
+ goToPayActivity()
+ }
+ }
+
+ private fun observe() {
+ viewModel.myYelloData.observe(viewLifecycleOwner) { state ->
+ binding.uiState = state.getUiStateModel()
+ when (state) {
+ is UiState.Success -> {
+ binding.shimmerMyYelloReceive.stopShimmer()
+ if (viewModel.isFirstLoading) {
+ startFadeIn()
+ viewModel.isFirstLoading = false
+ }
+ binding.clSendOpen.isVisible = state.data.ticketCount != 0
+ binding.btnSendCheck.isVisible = state.data.ticketCount == 0
+ binding.tvKeyNumber.text = state.data.ticketCount.toString()
+ adapter?.addItem(state.data.yello)
+ }
+
+ is UiState.Failure -> {
+ binding.shimmerMyYelloReceive.stopShimmer()
+ yelloSnackbar(requireView(), state.msg)
+ }
+
+ is UiState.Empty -> binding.shimmerMyYelloReceive.stopShimmer()
+
+ is UiState.Loading -> binding.shimmerMyYelloReceive.startShimmer()
+ }
+ }
+
+ viewModel.totalCount.flowWithLifecycle(viewLifecycleOwner.lifecycle).onEach {
+ binding.tvCount.text = it.toString()
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+
+ viewModel.voteCount.flowWithLifecycle(viewLifecycleOwner.lifecycle).onEach {
+ when (it) {
+ is UiState.Success -> (activity as? MainActivity)?.setBadgeCount(it.data.totalCount)
+
+ is UiState.Failure -> yelloSnackbar(binding.root, it.msg)
+
+ else -> {}
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private fun infinityScroll() {
+ binding.rvMyYelloReceive.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (dy > 0 && !binding.rvMyYelloReceive.canScrollVertically(1) && (recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() == adapter!!.itemCount - 1) {
+ viewModel.getMyYelloList()
+ }
+ }
+
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+ super.onScrollStateChanged(recyclerView, newState)
+ if (newState == RecyclerView.SCROLL_STATE_IDLE && !isScrolled) {
+ AmplitudeUtils.trackEventWithProperties("scroll_all_messages")
+ isScrolled = true
+ }
+ }
+ })
+ }
+
+ private fun goToPayActivity() {
+ Intent(requireContext(), PayActivity::class.java).apply {
+ payActivityLauncher.launch(this)
+ }
+ }
+
+ private val myYelloReadActivityLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) {
+ if (it.resultCode == Activity.RESULT_OK) {
+ it.data?.let { intent ->
+ val isHintUsed = intent.getBooleanExtra("isHintUsed", false)
+ val nameIndex = intent.getIntExtra("nameIndex", -1)
+ val ticketCount = intent.getIntExtra("ticketCount", -1)
+ val list = adapter?.currentList()
+ val selectItem = list?.get(viewModel.position)
+ selectItem?.apply {
+ this.isRead = true
+ this.isHintUsed = isHintUsed
+ this.nameHint = nameIndex
+ }
+ selectItem?.let {
+ adapter?.changeItem(viewModel.position, selectItem)
+ }
+ if (ticketCount != -1) {
+ binding.tvKeyNumber.text = ticketCount.toString()
+ }
+ binding.clSendOpen.isVisible = ticketCount != 0
+ binding.btnSendCheck.isVisible = ticketCount == 0
+
+ viewModel.getVoteCount()
+ }
+ }
+ }
+
+ private val payActivityLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) {
+ if (it.resultCode == Activity.RESULT_OK) {
+ it.data?.let { intent ->
+ val ticketCount = intent.getIntExtra("ticketCount", -1)
+ if (ticketCount != -1) {
+ binding.tvKeyNumber.text = ticketCount.toString()
+ }
+ binding.clSendOpen.isVisible = ticketCount != 0
+ binding.btnSendCheck.isVisible = ticketCount == 0
+ }
+ }
+ }
+
+ private fun initPullToScrollListener() {
+ binding.layoutMyYelloSwipe.apply {
+ setOnRefreshListener {
+ lifecycleScope.launch {
+ adapter?.clearList()
+ viewModel.setToFirstPage()
+ viewModel.getMyYelloList()
+ delay(200)
+ binding.layoutMyYelloSwipe.isRefreshing = false
+ }
+ }
+ setPullToScrollColor(R.color.grayscales_500, R.color.grayscales_700)
+ }
+ }
+
+ private fun startFadeIn() {
+ val animation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in)
+ binding.rvMyYelloReceive.startAnimation(animation)
+ }
+
+ fun scrollToTop() {
+ binding.rvMyYelloReceive.smoothScrollToPosition(0)
+ }
+
+ private fun setClickGoShopAmplitude(value: String) {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_go_shop",
+ JSONObject().put("shop_button", value),
+ )
+ }
+
+ override fun onDestroyView() {
+ adapter = null
+ super.onDestroyView()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloViewModel.kt
new file mode 100644
index 000000000..ad330b6d3
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/MyYelloViewModel.kt
@@ -0,0 +1,117 @@
+package com.el.yello.presentation.main.myyello
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.entity.MyYello
+import com.example.domain.entity.vote.VoteCount
+import com.example.domain.repository.YelloRepository
+import com.example.ui.view.UiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.math.ceil
+
+@HiltViewModel
+class MyYelloViewModel @Inject constructor(
+ private val repository: YelloRepository,
+) : ViewModel() {
+
+ // Todo Flow 쓰면 상세보기 갔다 왔을 때 리스트 계속 관찰해서 임시로 LiveData로 구현
+ private val _myYelloData = MutableLiveData>(UiState.Loading)
+ val myYelloData: LiveData> = _myYelloData
+
+ private val _totalCount = MutableStateFlow(0)
+ val totalCount: StateFlow = _totalCount.asStateFlow()
+
+ private val _voteCount = MutableStateFlow>(UiState.Loading)
+ val voteCount: StateFlow> = _voteCount.asStateFlow()
+
+ var isFirstLoading = true
+
+ var position: Int = -1
+ private set
+
+ private var currentPage = -1
+ private var isPagingFinish = false
+ private var totalPage = Int.MAX_VALUE
+
+ fun setPosition(pos: Int) {
+ position = pos
+ }
+
+ fun setToFirstPage() {
+ currentPage = -1
+ isPagingFinish = false
+ totalPage = Int.MAX_VALUE
+ isFirstLoading = true
+ }
+
+ fun getMyYelloList() {
+ if (isPagingFinish) return
+ viewModelScope.launch {
+ repository.getMyYelloList(++currentPage)
+ .onSuccess {
+ if (it == null) {
+ _myYelloData.value = UiState.Empty
+ return@launch
+ }
+ totalPage = ceil((it.totalCount * 0.1)).toInt() - 1
+ if (totalPage == currentPage) isPagingFinish = true
+ _myYelloData.value = when {
+ it.yello.isEmpty() -> UiState.Empty
+ else -> UiState.Success(it)
+ }
+ _totalCount.value = it.totalCount
+ setAmplitude(it)
+ }
+ .onFailure {
+ _myYelloData.value = UiState.Failure("내 쪽지 목록 서버 통신 실패")
+ }
+ }
+ }
+
+ fun getVoteCount() {
+ viewModelScope.launch {
+ repository.voteCount()
+ .onSuccess {
+ if (it != null) _voteCount.value = UiState.Success(it)
+ }
+ .onFailure {
+ _voteCount.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ private fun setAmplitude(myYello: MyYello) {
+ AmplitudeUtils.updateUserIntProperties(
+ "user_message_received",
+ myYello.totalCount,
+ )
+ AmplitudeUtils.updateUserIntProperties(
+ "user_message_open",
+ myYello.openCount,
+ )
+ AmplitudeUtils.updateUserIntProperties(
+ "user_message_open_keyword",
+ myYello.openKeywordCount,
+ )
+ AmplitudeUtils.updateUserIntProperties(
+ "user_message_open_firstletter",
+ myYello.openNameCount,
+ )
+ AmplitudeUtils.updateUserIntProperties(
+ "user_message_open_fullname",
+ myYello.openFullNameCount,
+ )
+ AmplitudeUtils.updateUserIntProperties(
+ "user_message_open_fullname",
+ myYello.openFullNameCount,
+ )
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/read/MyYelloReadActivity.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/read/MyYelloReadActivity.kt
new file mode 100644
index 000000000..ba4e7b582
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/read/MyYelloReadActivity.kt
@@ -0,0 +1,350 @@
+package com.el.yello.presentation.main.myyello.read
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.net.Uri
+import android.os.Bundle
+import android.provider.MediaStore
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.core.view.isGone
+import androidx.core.view.isInvisible
+import androidx.core.view.isVisible
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.ActivityMyYelloReadBinding
+import com.el.yello.presentation.pay.PayActivity
+import com.el.yello.util.Utils
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.entity.YelloDetail
+import com.example.domain.enum.PointEnum
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.toast
+import com.example.ui.intent.boolExtra
+import com.example.ui.intent.intExtra
+import com.example.ui.intent.longExtra
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+
+@AndroidEntryPoint
+class MyYelloReadActivity :
+ BindingActivity(R.layout.activity_my_yello_read) {
+ private val id by longExtra()
+ private val nameIndex by intExtra()
+ private val isHintUsed by boolExtra()
+ private val viewModel by viewModels()
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initView()
+ initClick()
+ observe()
+ trackAmplitudeEvent()
+ }
+
+ private fun trackAmplitudeEvent() {
+ with(viewModel.yelloDetail ?: return) {
+ if (nameHint != -3) {
+ AmplitudeUtils.trackEventWithProperties("view_open_message")
+ }
+ if (!isAnswerRevealed && nameHint == -2) {
+ AmplitudeUtils.trackEventWithProperties("view_open_fullnamefirst")
+ }
+ if (isAnswerRevealed && nameHint == -2) {
+ AmplitudeUtils.trackEventWithProperties("view_open_fullname")
+ }
+ if (isAnswerRevealed && nameHint == -1) {
+ AmplitudeUtils.trackEventWithProperties("view_open_keyword")
+ }
+ if (isAnswerRevealed && nameHint == 0 && !isSubscribe) {
+ AmplitudeUtils.trackEventWithProperties(
+ "view_open_firstletter",
+ JSONObject().put("subscription type", "sub_no"),
+ )
+ }
+ if (isAnswerRevealed && nameHint == 0 && isSubscribe) {
+ AmplitudeUtils.trackEventWithProperties(
+ "view_open_firstletter",
+ JSONObject().put("subscription type", "sub_yes"),
+ )
+ }
+ }
+ }
+
+ private fun initView() {
+ viewModel.getYelloDetail(id)
+ viewModel.setNameIndex(nameIndex)
+ viewModel.setHintUsed(isHintUsed)
+
+ binding.tv300.paintFlags = binding.tv300.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
+ }
+
+ private fun initClick() {
+ binding.tvInitialCheck.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties("click_open_keyword")
+ PointUseDialog.newInstance(
+ if (binding.tvInitialCheck.text.toString()
+ .contains("300")
+ ) {
+ viewModel.myPoint >= 300
+ } else {
+ viewModel.myPoint >= 100
+ },
+ if (binding.tvInitialCheck.text.toString()
+ .contains("300")
+ ) {
+ PointEnum.INITIAL.ordinal
+ } else {
+ PointEnum.KEYWORD.ordinal
+ },
+ ).show(supportFragmentManager, "dialog")
+ }
+
+ binding.btnSendCheck.setOnSingleClickListener {
+ if (binding.tvNameNotYet.isVisible && binding.tvKeywordNotYet.isVisible) {
+ AmplitudeUtils.trackEventWithProperties("click_open_fullnamefirst")
+ AmplitudeUtils.trackEventWithProperties(
+ "click_go_shop",
+ JSONObject().put("shop_button", "cta_nothing"),
+ )
+ } else if (viewModel.yelloDetail?.isSubscribe == true && binding.tvKeywordNotYet.isGone) {
+ AmplitudeUtils.trackEventWithProperties("click_open_fullname")
+ AmplitudeUtils.trackEventWithProperties(
+ "click_go_shop",
+ JSONObject().put("shop_button", "cta_keyword_sub"),
+ )
+ } else if (viewModel.yelloDetail?.isSubscribe == false && binding.tvKeywordNotYet.isGone) {
+ AmplitudeUtils.trackEventWithProperties("click_open_fullname")
+ AmplitudeUtils.trackEventWithProperties(
+ "click_go_shop",
+ JSONObject().put("shop_button", "cta_keyword_nosub"),
+ )
+ } else if ((viewModel.yelloDetail?.nameHint == 0 || viewModel.yelloDetail?.nameHint == 1) && binding.tvKeywordNotYet.isVisible) {
+ AmplitudeUtils.trackEventWithProperties("click_open_fullnamefirst")
+ AmplitudeUtils.trackEventWithProperties(
+ "click_go_shop",
+ JSONObject().put("shop_button", "cta_firstletter"),
+ )
+ }
+ Intent(this, PayActivity::class.java).apply {
+ startActivity(this)
+ }
+ }
+
+ binding.clSendOpen.setOnSingleClickListener {
+ if (binding.tvKeywordNotYet.isVisible) {
+ AmplitudeUtils.trackEventWithProperties("click_open_fullnamefirst")
+ } else {
+ AmplitudeUtils.trackEventWithProperties("click_open_fullname")
+ }
+ ReadingTicketUseDialog.newInstance(binding.tvKeywordNotYet.isGone)
+ .show(supportFragmentManager, "reading_ticket_dialog")
+ }
+
+ binding.ivBack.setOnSingleClickListener {
+ finish()
+ }
+
+ binding.btnInstagram.setOnSingleClickListener {
+ if (binding.tvNameNotYet.isVisible && binding.tvKeywordNotYet.isVisible) {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_instagram",
+ JSONObject().put("insta_view", "message"),
+ )
+ } else if (binding.tvNameNotYet.isVisible && binding.tvKeywordNotYet.isGone) {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_instagram",
+ JSONObject().put("insta_view", "keyword"),
+ )
+ } else if ((viewModel.yelloDetail?.nameHint == 0 || viewModel.yelloDetail?.nameHint == 1) && binding.tvKeywordNotYet.isVisible) {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_instagram",
+ JSONObject().put("insta_view", "firstletter"),
+ )
+ } else if (viewModel.yelloDetail?.nameHint == -2 && binding.tvKeywordNotYet.isGone) {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_instagram",
+ JSONObject().put("insta_view", "fullname"),
+ )
+ } else if (viewModel.yelloDetail?.nameHint == -2 && binding.tvKeywordNotYet.isVisible) {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_instagram",
+ JSONObject().put("insta_view", "fullnamefirst"),
+ )
+ }
+ setViewInstagram(true)
+ lifecycleScope.launch {
+ delay(500)
+ shareInstagramStory()
+ }
+ }
+
+ binding.clZeroInitialCheck.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties("click_open_firstletter")
+ PointUseDialog.newInstance(
+ true,
+ PointEnum.SUBSCRIBE.ordinal,
+ ).show(supportFragmentManager, "dialog")
+ }
+ }
+
+ private fun observe() {
+ viewModel.yelloDetailData.flowWithLifecycle(lifecycle)
+ .onEach {
+ binding.uiState = it.getUiStateModel()
+ when (it) {
+ is UiState.Success -> {
+ binding.data = it.data
+ setData(it.data)
+ }
+
+ is UiState.Failure -> {
+ toast(it.msg)
+ }
+
+ else -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setViewInstagram(isInstagram: Boolean) {
+ binding.clInstagramView.isVisible = isInstagram
+ binding.clTopView.isVisible = !isInstagram
+ binding.clBottomView.isVisible = !isInstagram
+ }
+
+ private fun setData(yello: YelloDetail) {
+ binding.tvSendName.isVisible = yello.nameHint != -1
+ binding.tvNameNotYet.isVisible = yello.nameHint == -1
+ binding.clSendOpen.isVisible = yello.ticketCount != 0
+ binding.btnSendCheck.isVisible = yello.ticketCount == 0
+ binding.tvKeyNumber.text = yello.ticketCount.toString()
+ if (yello.nameHint >= 0) {
+ binding.tvSendName.text =
+ Utils.setChosungText(yello.senderName, yello.nameHint)
+ } else if (yello.nameHint == -2 || yello.nameHint == -3) {
+ binding.tvSendName.text = yello.senderName
+ }
+ binding.tvInitialCheck.isVisible = !(yello.nameHint >= 0 && yello.isAnswerRevealed)
+ binding.tvGender.text =
+ if (yello.senderGender.contains("FEMALE")) {
+ getString(R.string.my_yello_female)
+ } else {
+ getString(
+ R.string.my_yello_male,
+ )
+ }
+ binding.tvInitialCheck.text =
+ if (yello.isAnswerRevealed) "300포인트로 초성 1개 확인하기" else "100포인트로 키워드 확인하기"
+
+ if (yello.isSubscribe) {
+ binding.tvInitialCheck.isInvisible = yello.isAnswerRevealed
+ binding.clZeroInitialCheck.isVisible = yello.isAnswerRevealed && yello.nameHint == -1
+ }
+
+ if (yello.nameHint == -2) {
+ if (yello.isAnswerRevealed) {
+ binding.clZeroInitialCheck.isVisible = false
+ binding.tvInitialCheck.isVisible = false
+ }
+ binding.btnSendCheck.isVisible = false
+ binding.clSendOpen.isVisible = false
+ } else if (yello.nameHint == -3) {
+ binding.clZeroInitialCheck.isVisible = false
+ binding.tvInitialCheck.isVisible = false
+ binding.clSendOpen.isVisible = false
+ binding.btnSendCheck.isVisible = false
+ }
+ binding.tvNameCheckFinish.isVisible = yello.nameHint == -2 || yello.nameHint == -3
+
+ trackAmplitudeEvent()
+ }
+
+ private fun shareInstagramStory() {
+ val backgroundAssetUri = makeUriFromView(binding.layout)
+ // Instantiate an intent
+ val intent = Intent("com.instagram.share.ADD_TO_STORY").apply {
+ setDataAndType(backgroundAssetUri, "image/jpeg")
+ flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ }
+ try {
+ instagramLauncher.launch(intent)
+ } catch (e: ActivityNotFoundException) {
+ val playStoreIntent = Intent(Intent.ACTION_VIEW)
+ playStoreIntent.data = Uri.parse("market://details?id=com.instagram.android")
+ startActivity(playStoreIntent)
+ }
+ }
+
+ private fun makeUriFromView(view: View): Uri? {
+ val bitmap = makeBitmapFromView(view)
+
+ // temp file의 이름을 정합니다.
+ // 확장자_YYYYMMDDHHMMSS
+ val time = System.currentTimeMillis()
+ val dayTime = SimpleDateFormat("yyyymmddhhmmss")
+ val fileName = "JPEG_" + dayTime.format(Date(time)) + "_" + time.toString()
+
+ val bytes = ByteArrayOutputStream()
+
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 100, bytes)
+ val path =
+ MediaStore.Images.Media.insertImage(contentResolver, bitmap, fileName, null)
+
+ return Uri.parse(path)
+ }
+
+ private fun makeBitmapFromView(view: View): Bitmap {
+ val bitmap =
+ Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ view.draw(canvas)
+ return bitmap
+ }
+
+ private val instagramLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) {
+ setViewInstagram(false)
+ }
+
+ override fun finish() {
+ viewModel.isHintUsed?.let {
+ intent.putExtra("isHintUsed", it)
+ }
+ viewModel.nameIndex?.let {
+ intent.putExtra("nameIndex", it)
+ }
+ intent.putExtra("ticketCount", viewModel.myReadingTicketCount)
+ setResult(RESULT_OK, intent)
+ super.finish()
+ }
+
+ companion object {
+ fun getIntent(
+ context: Context,
+ id: Long,
+ nameIndex: Int? = null,
+ isHintUsed: Boolean? = null,
+ ) =
+ Intent(context, MyYelloReadActivity::class.java)
+ .putExtra("id", id)
+ .putExtra("nameIndex", nameIndex)
+ .putExtra("isHintUsed", isHintUsed)
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/read/MyYelloReadViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/read/MyYelloReadViewModel.kt
new file mode 100644
index 000000000..d1919db56
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/read/MyYelloReadViewModel.kt
@@ -0,0 +1,148 @@
+package com.el.yello.presentation.main.myyello.read
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.entity.CheckKeyword
+import com.example.domain.entity.CheckName
+import com.example.domain.entity.FullName
+import com.example.domain.entity.YelloDetail
+import com.example.domain.enum.PointEnum
+import com.example.domain.repository.YelloRepository
+import com.example.ui.view.UiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MyYelloReadViewModel @Inject constructor(
+ private val repository: YelloRepository,
+) : ViewModel() {
+ private val _yelloDetailData = MutableStateFlow>(UiState.Loading)
+ val yelloDetailData: StateFlow> = _yelloDetailData.asStateFlow()
+
+ private val _keywordData = MutableStateFlow>(UiState.Loading)
+ val keywordData: StateFlow> = _keywordData.asStateFlow()
+
+ private val _nameData = MutableStateFlow>(UiState.Loading)
+ val nameData: StateFlow> = _nameData.asStateFlow()
+
+ private val _fullNameData = MutableStateFlow>(UiState.Loading)
+ val fullNameData: StateFlow> = _fullNameData.asStateFlow()
+
+ var pointType = 0
+ var isTwoButton = false
+ var myPoint = 0
+ private set
+ var myReadingTicketCount = 0
+ private set
+
+ private var voteId: Long = -1
+
+ var nameIndex: Int? = null
+ private set
+ var isHintUsed: Boolean? = null
+ private set
+
+ var yelloDetail: YelloDetail? = null
+ private set
+
+ fun setNameIndex(index: Int) {
+ nameIndex = index
+ }
+
+ fun setHintUsed(isUsed: Boolean) {
+ isHintUsed = isUsed
+ }
+
+ fun setPointAndIsTwoButton(type: Int, isButton: Boolean) {
+ pointType = type
+ isTwoButton = isButton
+ }
+
+ private fun minusPoint(): Int {
+ myPoint -= if (pointType == PointEnum.KEYWORD.ordinal) {
+ 100
+ } else {
+ 300
+ }
+ return myPoint
+ }
+
+ private fun minusTicket() {
+ myReadingTicketCount -= 1
+ }
+
+ fun getYelloDetail(id: Long = voteId) {
+ voteId = id
+ viewModelScope.launch {
+ repository.getYelloDetail(id)
+ .onSuccess {
+ if (it == null) {
+ _yelloDetailData.value = UiState.Empty
+ return@launch
+ }
+ myReadingTicketCount = it.ticketCount
+ myPoint = it.currentPoint
+ yelloDetail = it
+ _yelloDetailData.value = UiState.Success(it)
+ AmplitudeUtils.updateUserIntProperties("user_point", it.currentPoint)
+ }.onFailure {
+ _yelloDetailData.value = UiState.Failure("옐로 상세보기 서버 통신 실패")
+ }
+ }
+ }
+
+ fun checkKeyword() {
+ viewModelScope.launch {
+ repository.checkKeyword(voteId)
+ .onSuccess {
+ if (it == null) {
+ _keywordData.value = UiState.Empty
+ } else {
+ minusPoint()
+ _keywordData.value = UiState.Success(it)
+ }
+ }.onFailure {
+ _keywordData.value = UiState.Failure("키워드 확인 서버 통신 실패")
+ }
+ }
+ }
+
+ fun checkInitial() {
+ viewModelScope.launch {
+ repository.checkName(voteId)
+ .onSuccess {
+ if (it == null) {
+ _nameData.value = UiState.Empty
+ } else {
+ if (pointType == PointEnum.INITIAL.ordinal) {
+ minusPoint()
+ }
+ _nameData.value = UiState.Success(it)
+ }
+ }.onFailure {
+ _nameData.value = UiState.Failure("이름 확인 서버 통신 실패")
+ }
+ }
+ }
+
+ fun postFullName() {
+ viewModelScope.launch {
+ repository.postFullName(voteId)
+ .onSuccess {
+ if (it == null) {
+ _fullNameData.value = UiState.Empty
+ } else {
+ minusTicket()
+ _fullNameData.value = UiState.Success(it)
+ }
+ }.onFailure {
+ _fullNameData.value = UiState.Failure("열람권 사용하기 서버 통신 실패")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/read/PointAfterDialog.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/read/PointAfterDialog.kt
new file mode 100644
index 000000000..7014b3877
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/read/PointAfterDialog.kt
@@ -0,0 +1,119 @@
+package com.el.yello.presentation.main.myyello.read
+
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import android.view.animation.AnimationUtils
+import androidx.core.view.isVisible
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.DialogPointAfterBinding
+import com.el.yello.util.Utils
+import com.example.domain.enum.PointEnum
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class PointAfterDialog :
+ BindingDialogFragment(R.layout.dialog_point_after) {
+ private val viewModel by activityViewModels()
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initView()
+ observe()
+ setDialogBackground()
+ initEvent()
+ }
+
+ private fun initView() {
+ binding.tvInitial.visibility = View.INVISIBLE
+ if (viewModel.pointType == PointEnum.KEYWORD.ordinal) {
+ viewModel.checkKeyword()
+ } else {
+ viewModel.checkInitial()
+ }
+ setDataView()
+ }
+
+ private fun setDataView() {
+ binding.tvSubTitle.isVisible = viewModel.pointType == PointEnum.INITIAL.ordinal
+ if (viewModel.pointType == PointEnum.KEYWORD.ordinal) {
+ binding.tvTitle.text = getString(R.string.dialog_after_keyword_title)
+ }
+ }
+
+ private fun observe() {
+ viewModel.keywordData.flowWithLifecycle(viewLifecycleOwner.lifecycle).onEach {
+ when (it) {
+ is UiState.Success -> {
+ binding.tvPoint.text = viewModel.myPoint.toString()
+ setAnswerWithFadeIn(it.data.answer)
+ viewModel.getYelloDetail()
+ viewModel.setHintUsed(true)
+ }
+
+ is UiState.Failure -> {
+ toast(it.msg)
+ }
+
+ else -> return@onEach
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+
+ viewModel.nameData.flowWithLifecycle(viewLifecycleOwner.lifecycle).onEach {
+ when (it) {
+ is UiState.Success -> {
+ binding.tvPoint.text = viewModel.myPoint.toString()
+ binding.tvInitial.text = Utils.setChosungText(it.data.name, 0)
+ viewModel.getYelloDetail()
+ viewModel.setNameIndex(it.data.index)
+ }
+
+ is UiState.Failure -> {
+ toast(it.msg)
+ }
+
+ else -> return@onEach
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private fun setAnswerWithFadeIn(text: String) {
+ val animation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in)
+ binding.tvInitial.startAnimation(animation)
+ binding.tvInitial.text = text
+ binding.tvInitial.isVisible = true
+ }
+
+ private fun initEvent() {
+ binding.tvOk.setOnSingleClickListener {
+ dismiss()
+ }
+ }
+
+ private fun setDialogBackground() {
+ val deviceWidth = Resources.getSystem().displayMetrics.widthPixels
+ val dialogHorizontalMargin = (Resources.getSystem().displayMetrics.density * 16) * 2
+
+ dialog?.window?.apply {
+ setLayout(
+ (deviceWidth - dialogHorizontalMargin * 2).toInt(),
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.drawable.shape_fill_gray900_12dp)
+ }
+ dialog?.setCancelable(true)
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance() = PointAfterDialog()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/read/PointUseDialog.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/read/PointUseDialog.kt
new file mode 100644
index 000000000..fb87156ca
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/read/PointUseDialog.kt
@@ -0,0 +1,124 @@
+package com.el.yello.presentation.main.myyello.read
+
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import androidx.core.view.isVisible
+import androidx.fragment.app.activityViewModels
+import com.el.yello.R
+import com.el.yello.databinding.DialogPointUseBinding
+import com.el.yello.presentation.main.MainActivity
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.enum.PointEnum
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.view.setOnSingleClickListener
+
+class PointUseDialog : BindingDialogFragment(R.layout.dialog_point_use) {
+ private val viewModel by activityViewModels()
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initView()
+ setDialogBackground()
+ initEvent()
+ }
+
+ private fun initView() {
+ viewModel.setPointAndIsTwoButton(
+ arguments?.getInt("point_type") ?: 0,
+ arguments?.getBoolean("is_two_button") ?: false,
+ )
+ setDataView()
+ binding.tvPoint.text = viewModel.myPoint.toString()
+ }
+
+ private fun setDataView() {
+ binding.tvNo.isVisible = viewModel.isTwoButton
+ binding.ivClose.isVisible = !viewModel.isTwoButton
+ if (!viewModel.isTwoButton) {
+ binding.tvOk.text = getString(R.string.dialog_vote_get_point)
+ } else {
+ when (viewModel.pointType) {
+ PointEnum.INITIAL.ordinal -> {
+ binding.tvTitle.text = getString(R.string.dialog_get_initial_question)
+ binding.tvOk.text = getString(R.string.dialog_get_initial)
+ }
+ PointEnum.KEYWORD.ordinal -> {
+ binding.tvTitle.text = getString(R.string.dialog_get_keyword_question)
+ binding.tvOk.text = getString(R.string.dialog_get_keyword)
+ }
+ else -> {
+ binding.tvTitle.text = getString(R.string.dialog_get_initial_free_question)
+ binding.tvOk.text = getString(R.string.dialog_get_initial)
+ }
+ }
+ }
+ }
+
+ private fun initEvent() {
+ binding.tvOk.setOnSingleClickListener {
+ if (binding.tvOk.text.contains("투표")) {
+ Intent(activity, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ requireActivity().finish()
+ } else {
+ dismiss()
+ when (viewModel.pointType) {
+ PointEnum.INITIAL.ordinal -> {
+ AmplitudeUtils.trackEventWithProperties("click_modal_firstletter_yes")
+ }
+ PointEnum.SUBSCRIBE.ordinal -> {
+ AmplitudeUtils.trackEventWithProperties("click_modal_firstletter_yes")
+ }
+ PointEnum.KEYWORD.ordinal -> {
+ AmplitudeUtils.trackEventWithProperties("click_modal_keyword_yes")
+ }
+ }
+ PointAfterDialog.newInstance().show(parentFragmentManager, "dialog")
+ }
+ }
+
+ binding.tvNo.setOnSingleClickListener {
+ if (viewModel.pointType == PointEnum.INITIAL.ordinal) {
+ AmplitudeUtils.trackEventWithProperties("click_modal_firstletter_no")
+ } else {
+ AmplitudeUtils.trackEventWithProperties("click_modal_keyword_no")
+ }
+ dismiss()
+ }
+
+ binding.ivClose.setOnSingleClickListener {
+ dismiss()
+ }
+ }
+
+ private fun setDialogBackground() {
+ val deviceWidth = Resources.getSystem().displayMetrics.widthPixels
+ val dialogHorizontalMargin = (Resources.getSystem().displayMetrics.density * 16) * 2
+
+ dialog?.window?.apply {
+ setLayout(
+ (deviceWidth - dialogHorizontalMargin * 2).toInt(),
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.drawable.shape_fill_gray900_12dp)
+ }
+ dialog?.setCancelable(true)
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance(isTwoButton: Boolean, pointType: Int) =
+ PointUseDialog().apply {
+ arguments = Bundle().apply {
+ putInt("point_type", pointType)
+ putBoolean("is_two_button", isTwoButton)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/read/ReadingTicketAfterDialog.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/read/ReadingTicketAfterDialog.kt
new file mode 100644
index 000000000..edb3e0bfb
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/read/ReadingTicketAfterDialog.kt
@@ -0,0 +1,91 @@
+package com.el.yello.presentation.main.myyello.read
+
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import android.view.animation.AnimationUtils
+import androidx.core.view.isVisible
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.DialogReadingTicketAfterBinding
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class ReadingTicketAfterDialog :
+ BindingDialogFragment(R.layout.dialog_reading_ticket_after) {
+ private val viewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initView()
+ observe()
+ setDialogBackground()
+ initEvent()
+ }
+
+ private fun initView() {
+ viewModel.postFullName()
+ binding.tvName.visibility =View.INVISIBLE
+ }
+
+ private fun observe() {
+ viewModel.fullNameData.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach {
+ when (it) {
+ is UiState.Success -> {
+ binding.tvKey.text = viewModel.myReadingTicketCount.toString()
+ setAnswerWithFadeIn(it.data.name)
+ viewModel.getYelloDetail()
+ viewModel.setNameIndex(-2)
+ }
+
+ is UiState.Failure -> {
+ toast(it.msg)
+ }
+
+ else -> return@onEach
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private fun setAnswerWithFadeIn(text: String) {
+ val animation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in)
+ binding.tvName.startAnimation(animation)
+ binding.tvName.text = text
+ binding.tvName.isVisible = true
+ }
+
+ private fun initEvent() {
+ binding.tvOk.setOnSingleClickListener {
+ dismiss()
+ }
+ }
+
+ private fun setDialogBackground() {
+ val deviceWidth = Resources.getSystem().displayMetrics.widthPixels
+ val dialogHorizontalMargin = (Resources.getSystem().displayMetrics.density * 16) * 2
+
+ dialog?.window?.apply {
+ setLayout(
+ (deviceWidth - dialogHorizontalMargin * 2).toInt(),
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.drawable.shape_fill_gray900_12dp)
+ }
+ dialog?.setCancelable(true)
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance() =
+ ReadingTicketAfterDialog()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/myyello/read/ReadingTicketUseDialog.kt b/app/src/main/java/com/el/yello/presentation/main/myyello/read/ReadingTicketUseDialog.kt
new file mode 100644
index 000000000..6cb36d2d2
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/myyello/read/ReadingTicketUseDialog.kt
@@ -0,0 +1,86 @@
+package com.el.yello.presentation.main.myyello.read
+
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import androidx.core.os.bundleOf
+import androidx.fragment.app.activityViewModels
+import com.el.yello.R
+import com.el.yello.databinding.DialogReadingTicketUseBinding
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.view.setOnSingleClickListener
+
+class ReadingTicketUseDialog :
+ BindingDialogFragment(R.layout.dialog_reading_ticket_use) {
+ private val viewModel by activityViewModels()
+
+ private var _isKeywordOpened: Boolean? = null
+ private val isKeywordOpened
+ get() = _isKeywordOpened ?: false
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ getBundleArgs()
+ initView()
+ setDialogBackground()
+ initEvent()
+ }
+
+ private fun getBundleArgs() {
+ arguments ?: return
+ _isKeywordOpened = arguments?.getBoolean(ARGS_IS_KEYWORD_OPENED)
+ }
+
+ private fun initView() {
+ binding.tvKey.text = viewModel.myReadingTicketCount.toString()
+ }
+
+ private fun initEvent() {
+ binding.tvOk.setOnSingleClickListener {
+ if (isKeywordOpened) {
+ AmplitudeUtils.trackEventWithProperties("click_modal_fullname_yes")
+ } else {
+ AmplitudeUtils.trackEventWithProperties("click_modal_fullnamefirst_yes")
+ }
+ dismiss()
+ ReadingTicketAfterDialog.newInstance().show(parentFragmentManager, "dialog")
+ }
+
+ binding.tvNo.setOnSingleClickListener {
+ if (isKeywordOpened) {
+ AmplitudeUtils.trackEventWithProperties("click_modal_fullname_no")
+ } else {
+ AmplitudeUtils.trackEventWithProperties("click_modal_fullnamefirst_no")
+ }
+ dismiss()
+ }
+ }
+
+ private fun setDialogBackground() {
+ val deviceWidth = Resources.getSystem().displayMetrics.widthPixels
+ val dialogHorizontalMargin = (Resources.getSystem().displayMetrics.density * 16) * 2
+
+ dialog?.window?.apply {
+ setLayout(
+ (deviceWidth - dialogHorizontalMargin * 2).toInt(),
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.drawable.shape_fill_gray900_12dp)
+ }
+ dialog?.setCancelable(true)
+ }
+
+ companion object {
+ private const val ARGS_IS_KEYWORD_OPENED = "is_keyword_opened"
+
+ @JvmStatic
+ fun newInstance(isKeywordOpened: Boolean) = ReadingTicketUseDialog().apply {
+ arguments = bundleOf(
+ ARGS_IS_KEYWORD_OPENED to isKeywordOpened,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/ProfileViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/profile/ProfileViewModel.kt
new file mode 100644
index 000000000..44e7fe214
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/ProfileViewModel.kt
@@ -0,0 +1,206 @@
+package com.el.yello.presentation.main.profile
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.entity.PayInfoModel
+import com.example.domain.entity.ProfileFriendsListModel
+import com.example.domain.entity.ProfileUserModel
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.PayRepository
+import com.example.domain.repository.ProfileRepository
+import com.example.ui.view.UiState
+import com.kakao.sdk.user.UserApiClient
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.math.ceil
+
+@HiltViewModel
+class ProfileViewModel @Inject constructor(
+ private val profileRepository: ProfileRepository,
+ private val authRepository: AuthRepository,
+ private val payRepository: PayRepository,
+) : ViewModel() {
+
+ init {
+ resetPageVariable()
+ // resetStateVariable()
+ }
+
+ private val _getUserDataResult = MutableSharedFlow()
+ val getUserDataResult: SharedFlow = _getUserDataResult
+
+ private val _getFriendListState =
+ MutableStateFlow>(UiState.Empty)
+ val getFriendListState: StateFlow> = _getFriendListState
+
+ private val _deleteUserState = MutableStateFlow>(UiState.Empty)
+ val deleteUserState: StateFlow> = _deleteUserState
+
+ private val _deleteFriendState = MutableStateFlow>(UiState.Empty)
+ val deleteFriendState: StateFlow> = _deleteFriendState
+
+ private val _kakaoLogoutState = MutableStateFlow>(UiState.Empty)
+ val kakaoLogoutState: StateFlow> = _kakaoLogoutState
+
+ private val _kakaoQuitState = MutableStateFlow>(UiState.Empty)
+ val kakaoQuitState: StateFlow> = _kakaoQuitState
+
+ private val _getPurchaseInfoState = MutableStateFlow>(UiState.Empty)
+ val getPurchaseInfoState: StateFlow> = _getPurchaseInfoState
+
+ var isSubscribed: Boolean = false
+
+ var isItemBottomSheetRunning: Boolean = false
+
+ private var isFirstScroll: Boolean = true
+
+ private var currentPage = -1
+ private var isPagingFinish = false
+ private var totalPage = Int.MAX_VALUE
+
+ var myUserData = ProfileUserModel()
+ var myFriendCount = 0
+
+ var clickedUserData = ProfileUserModel()
+ var clickedItemPosition: Int? = null
+
+ fun setItemPosition(position: Int) {
+ clickedItemPosition = position
+ }
+
+ fun setDeleteFriendStateEmpty() {
+ _deleteFriendState.value = UiState.Empty
+ }
+
+ fun resetPageVariable() {
+ currentPage = -1
+ isPagingFinish = false
+ totalPage = Int.MAX_VALUE
+ }
+
+ fun resetStateVariable() {
+ _deleteFriendState.value = UiState.Empty
+ _deleteUserState.value = UiState.Empty
+ _kakaoLogoutState.value = UiState.Empty
+ _kakaoQuitState.value = UiState.Empty
+ _getFriendListState.value = UiState.Empty
+ _getPurchaseInfoState.value = UiState.Empty
+ }
+
+ fun getUserDataFromServer() {
+ viewModelScope.launch {
+ profileRepository.getUserData()
+ .onSuccess { profile ->
+ if (profile == null) return@launch
+ myUserData = profile.apply {
+ if (!this.yelloId.startsWith("@")) this.yelloId = "@" + this.yelloId
+ }
+ myFriendCount = profile.friendCount
+ }
+ .onFailure { t ->
+ _getUserDataResult.emit(false)
+ }
+ }
+ }
+
+ fun getFriendsListFromServer() {
+ if (isPagingFinish) return
+ if (isFirstScroll) {
+ _getFriendListState.value = UiState.Loading
+ isFirstScroll = false
+ }
+ viewModelScope.launch {
+ profileRepository.getFriendsData(
+ ++currentPage,
+ )
+ .onSuccess {
+ it ?: return@launch
+ totalPage = ceil((it.totalCount * 0.1)).toInt() - 1
+ if (totalPage == currentPage) isPagingFinish = true
+ _getFriendListState.value = UiState.Success(it)
+ AmplitudeUtils.updateUserIntProperties("user_friends", it.totalCount)
+ }
+ .onFailure {
+ _getFriendListState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun deleteUserDataToServer() {
+ viewModelScope.launch {
+ _deleteUserState.value = UiState.Loading
+ profileRepository.deleteUserData()
+ .onSuccess {
+ clearLocalInfo()
+ delay(300)
+ _deleteUserState.value = UiState.Success(it)
+ }
+ .onFailure {
+ _deleteUserState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun deleteFriendDataToServer(friendId: Long) {
+ viewModelScope.launch {
+ _deleteFriendState.value = UiState.Loading
+ profileRepository.deleteFriendData(friendId)
+ .onSuccess {
+ _deleteFriendState.value = UiState.Success(it)
+ }
+ .onFailure {
+ _deleteFriendState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun logoutKakaoAccount() {
+ UserApiClient.instance.logout { error ->
+ _kakaoLogoutState.value = UiState.Loading
+ if (error == null) {
+ _kakaoLogoutState.value = UiState.Success(Unit)
+ clearLocalInfo()
+ } else {
+ _kakaoLogoutState.value = UiState.Failure(error.message.toString())
+ }
+ }
+ }
+
+ fun quitKakaoAccount() {
+ UserApiClient.instance.unlink { error ->
+ _kakaoQuitState.value = UiState.Loading
+ if (error == null) {
+ _kakaoQuitState.value = UiState.Success(Unit)
+ } else {
+ _kakaoQuitState.value = UiState.Failure(error.message.toString())
+ }
+ }
+ }
+
+ fun getPurchaseInfoFromServer() {
+ viewModelScope.launch {
+ payRepository.getPurchaseInfo()
+ .onSuccess {
+ it ?: return@launch
+ val subStatus: String = if (it.isSubscribe) "yes" else "no"
+ AmplitudeUtils.updateUserProperties("user_subscription", subStatus)
+ AmplitudeUtils.updateUserIntProperties("user_ticket", it.ticketCount)
+ _getPurchaseInfoState.value = UiState.Success(it)
+ }
+ .onFailure {
+ _getPurchaseInfoState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ private fun clearLocalInfo() {
+ authRepository.clearLocalPref()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/detail/SchoolProfileDetailActivity.kt b/app/src/main/java/com/el/yello/presentation/main/profile/detail/SchoolProfileDetailActivity.kt
new file mode 100644
index 000000000..586450ba5
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/detail/SchoolProfileDetailActivity.kt
@@ -0,0 +1,17 @@
+package com.el.yello.presentation.main.profile.detail
+
+import android.os.Bundle
+import com.el.yello.R
+import com.el.yello.databinding.ActivityProfileSchoolDetailBinding
+import com.example.ui.base.BindingActivity
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SchoolProfileDetailActivity :
+ BindingActivity(R.layout.activity_profile_school_detail) {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/detail/UnivProfileDetailActivity.kt b/app/src/main/java/com/el/yello/presentation/main/profile/detail/UnivProfileDetailActivity.kt
new file mode 100644
index 000000000..15c19e536
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/detail/UnivProfileDetailActivity.kt
@@ -0,0 +1,112 @@
+package com.el.yello.presentation.main.profile.detail
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.ActivityProfileUnivDetailBinding
+import com.el.yello.presentation.main.profile.mod.UnivProfileModActivity
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.activity.navigateTo
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@AndroidEntryPoint
+class UnivProfileDetailActivity :
+ BindingActivity(R.layout.activity_profile_univ_detail) {
+
+ private val viewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ binding.vm = viewModel
+ initProfileModBtnListener()
+ initChangeThumbnailBtnListener()
+ initBackBtnListener()
+ observeUserDataState()
+ observeKakaoDataResult()
+ observeModProfileState()
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ viewModel.getUserDataFromServer()
+ }
+
+ private fun initProfileModBtnListener() {
+ binding.btnModSchool.setOnSingleClickListener {
+ this.navigateTo()
+ viewModel.resetViewModelState()
+ }
+ binding.btnModSubgroup.setOnSingleClickListener {
+ this.navigateTo()
+ viewModel.resetViewModelState()
+ }
+ binding.btnModYear.setOnSingleClickListener {
+ this.navigateTo()
+ viewModel.resetViewModelState()
+ }
+ }
+
+ private fun initChangeThumbnailBtnListener() {
+ binding.btnChangeKakaoImage.setOnSingleClickListener {
+ viewModel.getUserInfoFromKakao()
+ }
+ }
+
+ private fun initBackBtnListener() {
+ binding.btnProfileDetailBack.setOnSingleClickListener { finish() }
+ }
+
+ private fun observeUserDataState() {
+ viewModel.getUserDataState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ binding.ivProfileDetailThumbnail.setImageOrBasicThumbnail(state.data)
+ }
+
+ is UiState.Failure -> {
+ toast(getString(R.string.profile_error_user_data))
+ }
+
+ is UiState.Empty -> return@onEach
+
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeKakaoDataResult() {
+ viewModel.getKakaoInfoResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (!result) yelloSnackbar(binding.root, getString(R.string.msg_error))
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeModProfileState() {
+ viewModel.postToModProfileState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ binding.ivProfileDetailThumbnail.setImageOrBasicThumbnail(state.data)
+ toast(getString(R.string.profile_detail_image_change))
+ }
+
+ is UiState.Failure -> {
+ toast(getString(R.string.sign_in_error_connection))
+ }
+
+ is UiState.Empty -> return@onEach
+
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/detail/UnivProfileDetailViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/profile/detail/UnivProfileDetailViewModel.kt
new file mode 100644
index 000000000..359dacb3c
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/detail/UnivProfileDetailViewModel.kt
@@ -0,0 +1,101 @@
+package com.el.yello.presentation.main.profile.detail
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.domain.entity.ProfileModRequestModel
+import com.example.domain.repository.ProfileRepository
+import com.example.ui.view.UiState
+import com.kakao.sdk.user.UserApiClient
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class UnivProfileDetailViewModel @Inject constructor(
+ private val profileRepository: ProfileRepository,
+) : ViewModel() {
+
+ private val _getUserDataState = MutableStateFlow>(UiState.Empty)
+ val getUserDataState: StateFlow> = _getUserDataState
+
+ private val _getKakaoInfoResult = MutableSharedFlow()
+ val getKakaoInfoResult: SharedFlow = _getKakaoInfoResult
+
+ private val _postToModProfileState = MutableStateFlow>(UiState.Empty)
+ val postToModProfileState: StateFlow> = _postToModProfileState
+
+ val name = MutableLiveData("")
+ val id = MutableLiveData("")
+ val school = MutableLiveData("")
+ val subGroup = MutableLiveData("")
+ val admYear = MutableLiveData("")
+
+ private lateinit var myUserData: ProfileModRequestModel
+
+ fun resetViewModelState() {
+ _getUserDataState.value = UiState.Empty
+ _postToModProfileState.value = UiState.Empty
+ }
+
+
+ fun getUserDataFromServer() {
+ viewModelScope.launch {
+ profileRepository.getUserData()
+ .onSuccess { profile ->
+ if (profile == null) {
+ _getUserDataState.value = UiState.Failure(toString())
+ return@launch
+ }
+ name.value = profile.name
+ id.value = profile.yelloId
+ school.value = profile.groupName
+ subGroup.value = profile.subGroupName
+ admYear.value = profile.groupAdmissionYear.toString()
+ myUserData = ProfileModRequestModel(
+ profile.name,
+ profile.yelloId,
+ profile.gender,
+ profile.email,
+ profile.profileImageUrl,
+ profile.groupId,
+ profile.groupAdmissionYear
+ )
+ _getUserDataState.value = UiState.Success(profile.profileImageUrl)
+ }
+ .onFailure {
+ _getUserDataState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun getUserInfoFromKakao() {
+ UserApiClient.instance.me { user, _ ->
+ try {
+ myUserData.profileImageUrl = user?.kakaoAccount?.profile?.profileImageUrl.orEmpty()
+ postNewProfileImageToServer()
+ } catch (e: IllegalArgumentException) {
+ viewModelScope.launch {
+ _getKakaoInfoResult.emit(false)
+ }
+ }
+ }
+ }
+
+ private fun postNewProfileImageToServer() {
+ if (!::myUserData.isInitialized) _postToModProfileState.value = UiState.Failure(toString())
+ viewModelScope.launch {
+ profileRepository.postToModUserData(myUserData)
+ .onSuccess {
+ _postToModProfileState.value = UiState.Success(myUserData.profileImageUrl)
+ }
+ .onFailure {
+ _postToModProfileState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFragment.kt b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFragment.kt
new file mode 100644
index 000000000..d5ff39919
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFragment.kt
@@ -0,0 +1,294 @@
+package com.el.yello.presentation.main.profile.info
+
+import android.os.Bundle
+import android.view.View
+import android.view.animation.AnimationUtils
+import androidx.core.view.isVisible
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.FragmentProfileBinding
+import com.el.yello.presentation.main.profile.ProfileViewModel
+import com.el.yello.presentation.main.profile.detail.SchoolProfileDetailActivity
+import com.el.yello.presentation.main.profile.detail.UnivProfileDetailActivity
+import com.el.yello.presentation.main.profile.manage.ProfileManageActivity
+import com.el.yello.presentation.pay.PayActivity
+import com.el.yello.util.Utils.setPullToScrollColor
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.domain.entity.ProfileUserModel
+import com.example.ui.activity.navigateTo
+import com.example.ui.base.BindingFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class ProfileFragment : BindingFragment(R.layout.fragment_profile) {
+
+ private var _adapter: ProfileFriendAdapter? = null
+ private val adapter
+ get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ private val viewModel by activityViewModels()
+
+ private lateinit var friendsList: List
+
+ private lateinit var itemDivider: ProfileItemDecoration
+
+ private var profileFriendItemBottomSheet: ProfileFriendItemBottomSheet? = null
+
+ private var isScrolled: Boolean = false
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initProfileSetting()
+ initPullToScrollListener()
+ setInfinityScroll()
+ setDeleteAnimation()
+ observeUserDataResult()
+ observeFriendsDataState()
+ observeFriendDeleteState()
+ observeCheckIsSubscribed()
+ AmplitudeUtils.trackEventWithProperties("view_profile")
+ }
+
+ private fun initProfileSetting() {
+ initProfileManageBtnListener()
+ initUpwardBtnListener()
+ initUpwardBtnVisibility()
+ initAdapter()
+ setItemDivider()
+ viewModel.getUserDataFromServer()
+ viewModel.getFriendsListFromServer()
+ viewModel.getPurchaseInfoFromServer()
+ }
+
+ private fun setItemDivider() {
+ itemDivider = ProfileItemDecoration(requireContext())
+ binding.rvProfileFriendsList.addItemDecoration(itemDivider)
+ }
+
+ private fun initProfileManageBtnListener() {
+ binding.btnProfileManage.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties("click_profile_manage")
+ activity?.navigateTo()
+ viewModel.resetStateVariable()
+ }
+ }
+
+ private fun initUpwardBtnListener() {
+ binding.fabUpward.setOnSingleClickListener {
+ binding.rvProfileFriendsList.scrollToPosition(0)
+ }
+ }
+
+ private fun initUpwardBtnVisibility() {
+ binding.rvProfileFriendsList.setOnScrollChangeListener { view, _, _, _, _ ->
+ binding.fabUpward.isVisible = view.canScrollVertically(-1)
+ }
+ }
+
+ private fun initAdapter() {
+ _adapter = ProfileFriendAdapter(
+ viewModel,
+ itemClick = { profileUserModel, position ->
+ initItemClickListener(
+ profileUserModel,
+ position,
+ )
+ },
+ shopClick = { initShopClickListener() },
+ modClick = { initProfileModClickListener() },
+ )
+ binding.rvProfileFriendsList.adapter = adapter
+ }
+
+ private fun initItemClickListener(profileUserModel: ProfileUserModel, position: Int) {
+ viewModel.setItemPosition(position)
+ viewModel.clickedUserData = profileUserModel.apply {
+ if (!this.yelloId.startsWith("@")) this.yelloId = "@" + this.yelloId
+ }
+ if (!viewModel.isItemBottomSheetRunning) {
+ AmplitudeUtils.trackEventWithProperties("click_profile_friend")
+ profileFriendItemBottomSheet = ProfileFriendItemBottomSheet()
+ profileFriendItemBottomSheet?.show(parentFragmentManager, ITEM_BOTTOM_SHEET)
+ }
+ }
+
+ private fun initShopClickListener() {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_go_shop",
+ JSONObject().put("shop_button", "profile_shop"),
+ )
+ activity?.navigateTo()
+ viewModel.resetStateVariable()
+ }
+
+ private fun initProfileModClickListener() {
+ when (viewModel.myUserData.groupType) {
+ TYPE_UNIVERSITY -> {
+ activity?.navigateTo()
+ viewModel.resetStateVariable()
+ }
+
+ TYPE_HIGH_SCHOOL, TYPE_MIDDLE_SCHOOL -> {
+ activity?.navigateTo()
+ viewModel.resetStateVariable()
+ }
+
+ else -> toast(getString(R.string.sign_in_error_connection))
+ }
+ }
+
+ private fun initPullToScrollListener() {
+ binding.layoutProfileSwipe.apply {
+ setOnRefreshListener {
+ lifecycleScope.launch {
+ adapter.setItemList(listOf())
+ viewModel.run {
+ resetPageVariable()
+ resetStateVariable()
+ getPurchaseInfoFromServer()
+ getUserDataFromServer()
+ getFriendsListFromServer()
+ }
+ delay(200)
+ binding.layoutProfileSwipe.isRefreshing = false
+ }
+ }
+ setPullToScrollColor(R.color.grayscales_500, R.color.grayscales_700)
+ }
+ }
+
+ private fun observeUserDataResult() {
+ viewModel.getUserDataResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (!result) yelloSnackbar(requireView(), getString(R.string.profile_error_user_data))
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeFriendsDataState() {
+ viewModel.getFriendListState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ binding.ivProfileLoading.isVisible = false
+ friendsList = state.data.friends
+ adapter.addItemList(friendsList)
+ }
+
+ is UiState.Failure -> {
+ yelloSnackbar(requireView(), getString(R.string.profile_error_friend_list))
+ }
+
+ is UiState.Loading -> {
+ binding.ivProfileLoading.isVisible = true
+ }
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setInfinityScroll() {
+ binding.rvProfileFriendsList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (dy > 0) {
+ recyclerView.layoutManager?.let { layoutManager ->
+ if (!binding.rvProfileFriendsList.canScrollVertically(1) && layoutManager is LinearLayoutManager && layoutManager.findLastVisibleItemPosition() == adapter.itemCount - 1) {
+ viewModel.getFriendsListFromServer()
+ }
+ }
+ }
+ }
+
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
+ super.onScrollStateChanged(recyclerView, newState)
+ if (newState == RecyclerView.SCROLL_STATE_IDLE && !isScrolled) {
+ AmplitudeUtils.trackEventWithProperties("scroll_profile_friends")
+ isScrolled = true
+ }
+ }
+ })
+ }
+
+ private fun observeFriendDeleteState() {
+ viewModel.deleteFriendState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ lifecycleScope.launch {
+ viewModel.clickedItemPosition?.let { position ->
+ adapter.removeItem(position)
+ }
+ binding.rvProfileFriendsList.removeItemDecoration(itemDivider)
+ delay(450)
+ binding.rvProfileFriendsList.addItemDecoration(itemDivider)
+ viewModel.myFriendCount -= 1
+ adapter.notifyDataSetChanged()
+ }
+ AmplitudeUtils.trackEventWithProperties("complete_profile_delete_friend")
+ }
+
+ is UiState.Failure -> toast(getString(R.string.profile_error_delete_friend))
+
+ is UiState.Loading -> return@onEach
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeCheckIsSubscribed() {
+ viewModel.getPurchaseInfoState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> viewModel.isSubscribed = state.data.isSubscribe == true
+
+ is UiState.Failure -> viewModel.isSubscribed = false
+
+ is UiState.Loading -> return@onEach
+
+ is UiState.Empty -> return@onEach
+ }
+ adapter.notifyDataSetChanged()
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setDeleteAnimation() {
+ binding.rvProfileFriendsList.itemAnimator = object : DefaultItemAnimator() {
+ override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean {
+ holder.itemView.animation =
+ AnimationUtils.loadAnimation(holder.itemView.context, R.anim.slide_out_right)
+ return super.animateRemove(holder)
+ }
+ }
+ }
+
+ fun scrollToTop() {
+ binding.rvProfileFriendsList.smoothScrollToPosition(0)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _adapter = null
+ if (profileFriendItemBottomSheet != null) profileFriendItemBottomSheet?.dismiss()
+ }
+
+ companion object {
+ const val ITEM_BOTTOM_SHEET = "itemBottomSheet"
+
+ const val TYPE_UNIVERSITY = "UNIVERSITY"
+ const val TYPE_HIGH_SCHOOL = "HIGH_SCHOOL"
+ const val TYPE_MIDDLE_SCHOOL = "MIDDLE_SCHOOL"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendAdapter.kt b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendAdapter.kt
new file mode 100644
index 000000000..75a86c168
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendAdapter.kt
@@ -0,0 +1,100 @@
+package com.el.yello.presentation.main.profile.info
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.ItemProfileFriendsListBinding
+import com.el.yello.databinding.ItemProfileUserInfoBinding
+import com.el.yello.presentation.main.profile.ProfileViewModel
+import com.example.domain.entity.ProfileUserModel
+import com.example.ui.view.ItemDiffCallback
+
+class ProfileFriendAdapter(
+ private val viewModel: ProfileViewModel,
+ private val itemClick: (ProfileUserModel, Int) -> (Unit),
+ private val shopClick: (Unit) -> (Unit),
+ private val modClick: (Unit) -> (Unit),
+) : ListAdapter(diffUtil) {
+
+ private var itemList = mutableListOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ val inflater by lazy { LayoutInflater.from(parent.context) }
+
+ return when (viewType) {
+ VIEW_TYPE_USER_INFO -> ProfileUserInfoViewHolder(
+ ItemProfileUserInfoBinding.inflate(inflater, parent, false),
+ shopClick,
+ modClick
+ )
+
+ VIEW_TYPE_FRIENDS_LIST -> ProfileListInfoViewHolder(
+ ItemProfileFriendsListBinding.inflate(inflater, parent, false),
+ itemClick,
+ )
+
+ else -> throw ClassCastException(
+ parent.context.getString(
+ R.string.view_type_error_msg,
+ viewType,
+ ),
+ )
+ }
+ }
+
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ when (holder) {
+ is ProfileUserInfoViewHolder -> {
+ holder.onBind(viewModel)
+ }
+
+ is ProfileListInfoViewHolder -> {
+ val itemPosition = position - HEADER_COUNT
+ holder.onBind(itemList[itemPosition], itemPosition)
+ }
+ }
+ val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams
+ layoutParams.bottomMargin = if (position == itemList.size) 24 else 0
+ holder.itemView.layoutParams = layoutParams
+ }
+
+ override fun getItemCount() = itemList.size + HEADER_COUNT
+
+ override fun getItemViewType(position: Int) =
+ when (position) {
+ 0 -> VIEW_TYPE_USER_INFO
+ else -> VIEW_TYPE_FRIENDS_LIST
+ }
+
+ fun addItemList(newItems: List) {
+ this.itemList.addAll(newItems)
+ notifyDataSetChanged()
+ }
+
+ fun setItemList(itemList: List) {
+ this.itemList = itemList.toMutableList()
+ notifyDataSetChanged()
+ }
+
+ fun removeItem(position: Int) {
+ if (this.itemList.isNotEmpty()) {
+ this.itemList.removeAt(position)
+ notifyItemRemoved(position + HEADER_COUNT)
+ notifyItemRangeChanged(position + HEADER_COUNT, itemCount)
+ }
+ }
+
+ companion object {
+ private val diffUtil = ItemDiffCallback(
+ onItemsTheSame = { old, new -> old.userId == new.userId },
+ onContentsTheSame = { old, new -> old == new },
+ )
+
+ private const val HEADER_COUNT = 1
+
+ private const val VIEW_TYPE_USER_INFO = 0
+ private const val VIEW_TYPE_FRIENDS_LIST = 1
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendDeleteBottomSheet.kt b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendDeleteBottomSheet.kt
new file mode 100644
index 000000000..7b3f58472
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendDeleteBottomSheet.kt
@@ -0,0 +1,79 @@
+package com.el.yello.presentation.main.profile.info
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentProfileDeleteBottomSheetBinding
+import com.el.yello.presentation.main.profile.ProfileViewModel
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.ui.base.BindingBottomSheetDialog
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class ProfileFriendDeleteBottomSheet :
+ BindingBottomSheetDialog(R.layout.fragment_profile_delete_bottom_sheet) {
+
+ private val viewModel by activityViewModels()
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setBackgroundDrawableResource(R.color.transparent)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.vm = viewModel
+ setItemImage()
+ initReturnBtnListener()
+ initDeleteBtnListener()
+ observeFriendDeleteState()
+ }
+
+ private fun setItemImage() {
+ binding.ivProfileFriendDeleteThumbnail.setImageOrBasicThumbnail(viewModel.clickedUserData.profileImageUrl)
+ }
+
+ private fun initReturnBtnListener() {
+ binding.btnProfileFriendDeleteReturn.setOnSingleClickListener {
+ dismiss()
+ }
+ }
+
+ private fun initDeleteBtnListener() {
+ binding.btnProfileFriendDeleteResume.setOnSingleClickListener {
+ viewModel.clickedUserData.userId.let { friendId ->
+ viewModel.deleteFriendDataToServer(friendId)
+ }
+ }
+ }
+
+ private fun observeFriendDeleteState() {
+ viewModel.deleteFriendState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ toast(
+ getString(
+ R.string.profile_delete_bottom_sheet_toast,
+ viewModel.clickedUserData.name,
+ ),
+ )
+ viewModel.setDeleteFriendStateEmpty()
+ this@ProfileFriendDeleteBottomSheet.dismiss()
+ }
+
+ is UiState.Failure -> toast(getString(R.string.profile_error_delete_friend))
+
+ is UiState.Loading -> return@onEach
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendItemBottomSheet.kt b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendItemBottomSheet.kt
new file mode 100644
index 000000000..ed0524253
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileFriendItemBottomSheet.kt
@@ -0,0 +1,59 @@
+package com.el.yello.presentation.main.profile.info
+
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import com.el.yello.R
+import com.el.yello.databinding.FragmentProfileItemBottomSheetBinding
+import com.el.yello.presentation.main.profile.ProfileViewModel
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.ui.base.BindingBottomSheetDialog
+import com.example.ui.view.setOnSingleClickListener
+
+class ProfileFriendItemBottomSheet :
+ BindingBottomSheetDialog(R.layout.fragment_profile_item_bottom_sheet) {
+
+ private var deleteBottomSheet: ProfileFriendDeleteBottomSheet? =
+ ProfileFriendDeleteBottomSheet()
+ private val viewModel by activityViewModels()
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setBackgroundDrawableResource(R.color.transparent)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.vm = viewModel
+ viewModel.isItemBottomSheetRunning = true
+ setItemImage()
+ initDeleteBtnListener()
+ }
+
+ private fun setItemImage() {
+ binding.ivProfileFriendThumbnail.setImageOrBasicThumbnail(viewModel.clickedUserData.profileImageUrl)
+ }
+
+ private fun initDeleteBtnListener() {
+ binding.btnProfileFriendDelete.setOnSingleClickListener {
+ deleteBottomSheet?.show(parentFragmentManager, DELETE_BOTTOM_SHEET)
+ dismiss()
+ }
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ super.onDismiss(dialog)
+ viewModel.isItemBottomSheetRunning = false
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ deleteBottomSheet = null
+ }
+
+ private companion object {
+ const val DELETE_BOTTOM_SHEET = "deleteBottomSheet"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileItemDecoration.kt b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileItemDecoration.kt
new file mode 100644
index 000000000..3e2e73937
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileItemDecoration.kt
@@ -0,0 +1,61 @@
+package com.el.yello.presentation.main.profile.info
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Rect
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.example.ui.number.dpToPx
+
+class ProfileItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
+ private val dividerHeight = 1.dpToPx(context)
+ private val dividerMargin = 24.dpToPx(context)
+ private val dividerColor = ContextCompat.getColor(context, R.color.grayscales_800)
+ private val dividerPaint = Paint()
+
+ init {
+ dividerPaint.color = dividerColor
+ }
+
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State,
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+
+ // 헤더와 리스트 사이 디바이더 출력 안나오도록 설정
+ val position = parent.getChildAdapterPosition(view)
+ if (position == 0) {
+ outRect.setEmpty()
+ } else {
+ outRect.bottom = dividerHeight
+ }
+ }
+
+ override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
+ val left = parent.paddingLeft + dividerMargin
+ val right = parent.width - parent.paddingRight - dividerMargin
+
+ val childCount = parent.childCount
+ for (i in 0 until childCount - 1) {
+ val child = parent.getChildAt(i)
+ val params = child.layoutParams as RecyclerView.LayoutParams
+
+ val top = child.bottom + params.bottomMargin
+ val bottom = top + dividerHeight
+
+ c.drawRect(
+ left.toFloat(),
+ top.toFloat(),
+ right.toFloat(),
+ bottom.toFloat(),
+ dividerPaint,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileListInfoViewHolder.kt b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileListInfoViewHolder.kt
new file mode 100644
index 000000000..3be5064dd
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileListInfoViewHolder.kt
@@ -0,0 +1,27 @@
+package com.el.yello.presentation.main.profile.info
+
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.databinding.ItemProfileFriendsListBinding
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.domain.entity.ProfileUserModel
+import com.example.ui.view.setOnSingleClickListener
+
+class ProfileListInfoViewHolder(
+ val binding: ItemProfileFriendsListBinding,
+ private val itemClick: (ProfileUserModel, Int) -> (Unit),
+) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun onBind(item: ProfileUserModel, position: Int) {
+ with(binding) {
+ tvProfileFriendItemName.text = item.name
+ tvProfileFriendItemSchool.text = item.group
+
+ ivProfileFriendItemThumbnail.setImageOrBasicThumbnail(item.profileImageUrl)
+
+ root.setOnSingleClickListener {
+ itemClick(item, position)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileUserInfoViewHolder.kt b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileUserInfoViewHolder.kt
new file mode 100644
index 000000000..481089aa1
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/info/ProfileUserInfoViewHolder.kt
@@ -0,0 +1,30 @@
+package com.el.yello.presentation.main.profile.info
+
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.databinding.ItemProfileUserInfoBinding
+import com.el.yello.presentation.main.profile.ProfileViewModel
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.ui.view.setOnSingleClickListener
+
+class ProfileUserInfoViewHolder(
+ val binding: ItemProfileUserInfoBinding,
+ val shopClick: (Unit) -> (Unit),
+ val modClick: (Unit) -> (Unit),
+) : RecyclerView.ViewHolder(binding.root) {
+
+ fun onBind(viewModel: ProfileViewModel) {
+ with(binding) {
+ vm = viewModel
+ ivSubsStar.isVisible = viewModel.isSubscribed
+ ivSubsLine.isVisible = viewModel.isSubscribed
+
+ btnProfileShop.setOnSingleClickListener { shopClick(Unit) }
+ btnProfileShopSale.setOnSingleClickListener { shopClick(Unit) }
+
+ btnPrifileMod.setOnSingleClickListener { modClick(Unit) }
+
+ ivProfileInfoThumbnail.setImageOrBasicThumbnail(viewModel.myUserData.profileImageUrl)
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileManageActivity.kt b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileManageActivity.kt
new file mode 100644
index 000000000..38b4faec2
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileManageActivity.kt
@@ -0,0 +1,129 @@
+package com.el.yello.presentation.main.profile.manage
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.BuildConfig
+import com.el.yello.R
+import com.el.yello.databinding.ActivityProfileManageBinding
+import com.el.yello.presentation.main.profile.ProfileViewModel
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingActivity
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class ProfileManageActivity :
+ BindingActivity(R.layout.activity_profile_manage) {
+
+ private val viewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initBackBtnListener()
+ initQuitBtnListener()
+ initCenterBtnListener()
+ initPrivacyBtnListener()
+ initServiceBtnListener()
+ initLogoutBtnListener()
+ setVersionCode()
+ observeKakaoLogoutState()
+ }
+
+ private fun initCenterBtnListener() {
+ binding.btnProfileManageCenter.setOnSingleClickListener {
+ startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(CUSTOMER_CENTER_URL)),
+ )
+ }
+ }
+
+ private fun initPrivacyBtnListener() {
+ binding.btnProfileManagePrivacy.setOnSingleClickListener {
+ startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(PRIVACY_URL)),
+ )
+ }
+ }
+
+ private fun initServiceBtnListener() {
+ binding.btnProfileManageService.setOnSingleClickListener {
+ startActivity(
+ Intent(Intent.ACTION_VIEW, Uri.parse(SERVICE_URL)),
+ )
+ }
+ }
+
+ private fun initLogoutBtnListener() {
+ binding.btnProfileManageLogout.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties("click_profile_logout")
+ viewModel.logoutKakaoAccount()
+ }
+ }
+
+ private fun initBackBtnListener() {
+ binding.btnProfileManageBack.setOnSingleClickListener { finish() }
+ }
+
+ private fun initQuitBtnListener() {
+ binding.btnProfileManageQuit.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_profile_withdrawal",
+ JSONObject().put("withdrawal_button", "withdrawal1"),
+ )
+ Intent(this, ProfileQuitOneActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ finish()
+ }
+ }
+
+ private fun setVersionCode() {
+ binding.tvProfileManageVersion.text =
+ getString(R.string.profile_manage_tv_version, BuildConfig.VERSION_NAME)
+ }
+
+ private fun observeKakaoLogoutState() {
+ viewModel.kakaoLogoutState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ AmplitudeUtils.trackEventWithProperties("complete_profile_logout")
+ lifecycleScope.launch {
+ delay(500)
+ restartApp()
+ }
+ }
+
+ is UiState.Failure -> yelloSnackbar(binding.root, getString(R.string.msg_error))
+
+ is UiState.Empty -> return@onEach
+
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun restartApp() {
+ val componentName = packageManager.getLaunchIntentForPackage(packageName)?.component
+ startActivity(Intent.makeRestartActivityTask(componentName))
+ Runtime.getRuntime().exit(0)
+ }
+
+ companion object {
+ const val CUSTOMER_CENTER_URL = "http://pf.kakao.com/_pcFzG/chat"
+ const val PRIVACY_URL = "https://yell0.notion.site/97f57eaed6c749bbb134c7e8dc81ab3f"
+ const val SERVICE_URL = "https://yell0.notion.site/2afc2a1e60774dfdb47c4d459f01b1d9"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitDialog.kt b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitDialog.kt
new file mode 100644
index 000000000..c7a7e7f97
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitDialog.kt
@@ -0,0 +1,113 @@
+package com.el.yello.presentation.main.profile.manage
+
+import android.content.Context
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentProfileQuitDialogBinding
+import com.el.yello.presentation.main.profile.ProfileViewModel
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class ProfileQuitDialog :
+ BindingDialogFragment(R.layout.fragment_profile_quit_dialog) {
+
+ private val viewModel by activityViewModels()
+
+ override fun onStart() {
+ super.onStart()
+ setDialogBackground()
+ }
+
+ private fun setDialogBackground() {
+ val deviceWidth = Resources.getSystem().displayMetrics.widthPixels
+ val dialogHorizontalMargin = (Resources.getSystem().displayMetrics.density * 16) * 2
+
+ dialog?.window?.apply {
+ setLayout(
+ (deviceWidth - dialogHorizontalMargin * 2).toInt(),
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.color.transparent)
+ }
+ dialog?.setCanceledOnTouchOutside(false)
+ dialog?.setCancelable(true)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initQuitBtnListener()
+ initRejectBtnListener()
+ observeUserDeleteState()
+ observeKakaoQuitState()
+ }
+
+ private fun initRejectBtnListener() {
+ binding.btnProfileDialogReject.setOnSingleClickListener { dismiss() }
+ }
+
+ private fun initQuitBtnListener() {
+ binding.btnProfileDialogQuit.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_profile_withdrawal",
+ JSONObject().put("withdrawal_button", "withdrawal4"),
+ )
+ viewModel.deleteUserDataToServer()
+ }
+ }
+
+ // 유저 탈퇴 서버 통신 성공 시 카카오 연결 해제 진행
+ private fun observeUserDeleteState() {
+ viewModel.deleteUserState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> viewModel.quitKakaoAccount()
+
+ is UiState.Failure -> toast(getString(R.string.profile_error_unlink))
+
+ is UiState.Loading -> return@onEach
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeKakaoQuitState() {
+ viewModel.kakaoQuitState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ AmplitudeUtils.trackEventWithProperties("complete_withdrawal")
+ restartApp(requireContext())
+ }
+
+ is UiState.Failure -> toast(getString(R.string.profile_error_unlink_kakao))
+
+ is UiState.Empty -> return@onEach
+
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun restartApp(context: Context) {
+ val packageManager = context.packageManager
+ val packageName = context.packageName
+ val componentName = packageManager.getLaunchIntentForPackage(packageName)?.component
+ context.startActivity(Intent.makeRestartActivityTask(componentName))
+ Runtime.getRuntime().exit(0)
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitOneActivity.kt b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitOneActivity.kt
new file mode 100644
index 000000000..b9d4ffd2b
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitOneActivity.kt
@@ -0,0 +1,45 @@
+package com.el.yello.presentation.main.profile.manage
+
+import android.content.Intent
+import android.os.Bundle
+import com.el.yello.R
+import com.el.yello.databinding.ActivityProfileQuitOneBinding
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingActivity
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class ProfileQuitOneActivity :
+ BindingActivity(R.layout.activity_profile_quit_one) {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initBackBtnListener()
+ initReturnBtnListener()
+ initQuitBtnListener()
+ }
+
+ private fun initBackBtnListener() {
+ binding.btnProfileQuitForSureBack.setOnSingleClickListener { finish() }
+ }
+
+ private fun initReturnBtnListener() {
+ binding.btnProfileQuitReturn.setOnSingleClickListener { finish() }
+ }
+
+ private fun initQuitBtnListener() {
+ binding.btnProfileQuitResume.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_profile_withdrawal",
+ JSONObject().put("withdrawal_button", "withdrawal2"),
+ )
+ Intent(this, ProfileQuitTwoActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitTwoActivity.kt b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitTwoActivity.kt
new file mode 100644
index 000000000..bed202e9b
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/manage/ProfileQuitTwoActivity.kt
@@ -0,0 +1,48 @@
+package com.el.yello.presentation.main.profile.manage
+
+import android.os.Bundle
+import com.el.yello.R
+import com.el.yello.databinding.ActivityProfileQuitTwoBinding
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingActivity
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class ProfileQuitTwoActivity :
+ BindingActivity(R.layout.activity_profile_quit_two) {
+
+ private var profileQuitDialog: ProfileQuitDialog? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initBackBtnListener()
+ initInviteDialogBtnListener()
+ }
+
+ private fun initBackBtnListener() {
+ binding.btnProfileQuitBack.setOnSingleClickListener { finish() }
+ }
+
+ private fun initInviteDialogBtnListener() {
+ binding.btnProfileQuitForSure.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ "click_profile_withdrawal",
+ JSONObject().put("withdrawal_button", "withdrawal3"),
+ )
+ profileQuitDialog = ProfileQuitDialog()
+ profileQuitDialog?.show(supportFragmentManager, QUIT_DIALOG)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ profileQuitDialog?.dismiss()
+ }
+
+ private companion object {
+ const val QUIT_DIALOG = "quitDialog"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/mod/SchoolProfileModActivity.kt b/app/src/main/java/com/el/yello/presentation/main/profile/mod/SchoolProfileModActivity.kt
new file mode 100644
index 000000000..c4b5c5153
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/mod/SchoolProfileModActivity.kt
@@ -0,0 +1,17 @@
+package com.el.yello.presentation.main.profile.mod
+
+import android.os.Bundle
+import com.el.yello.R
+import com.el.yello.databinding.ActivityProfileSchoolModBinding
+import com.example.ui.base.BindingActivity
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class SchoolProfileModActivity :
+ BindingActivity(R.layout.activity_profile_school_mod) {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivModSearchBottomSheet.kt b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivModSearchBottomSheet.kt
new file mode 100644
index 000000000..19374edf4
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivModSearchBottomSheet.kt
@@ -0,0 +1,253 @@
+package com.el.yello.presentation.main.profile.mod
+
+import android.app.Dialog
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import androidx.core.os.bundleOf
+import androidx.core.widget.doAfterTextChanged
+import androidx.core.widget.doOnTextChanged
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewModelScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.FragmentUnivModSearchBottomSheetBinding
+import com.el.yello.presentation.main.profile.mod.UnivProfileModViewModel.Companion.TEXT_NONE
+import com.el.yello.presentation.onboarding.fragment.universityinfo.department.DepartmentAdapter
+import com.el.yello.presentation.onboarding.fragment.universityinfo.department.SearchDialogDepartmentFragment.Companion.DEPARTMENT_FORM_URL
+import com.el.yello.presentation.onboarding.fragment.universityinfo.university.SearchDialogUniversityFragment.Companion.SCHOOL_FORM_URL
+import com.el.yello.presentation.onboarding.fragment.universityinfo.university.UniversityAdapter
+import com.example.ui.base.BindingBottomSheetDialog
+import com.example.ui.context.hideKeyboard
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+
+class UnivModSearchBottomSheet :
+ BindingBottomSheetDialog(R.layout.fragment_univ_mod_search_bottom_sheet) {
+
+ private val viewModel by activityViewModels()
+
+ private var _univAdapter: UniversityAdapter? = null
+ private val univAdapter
+ get() = requireNotNull(_univAdapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ private var _groupAdapter: DepartmentAdapter? = null
+ private val groupAdapter
+ get() = requireNotNull(_groupAdapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ private var searchJob: Job? = null
+ private var searchText: String = ""
+
+ private var isUnivSearch = true
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ checkIsUnivSearch()
+ initAdapter()
+ initSchoolFormBtnListener()
+ initBackBtnListener()
+ setDebounceSearch()
+ setListWithInfinityScroll()
+ setEmptyListWithTyping()
+ observeGetUnivListState()
+ observeGetUnivGroupIdListState()
+ setHideKeyboard()
+ }
+
+ private fun checkIsUnivSearch() {
+ isUnivSearch = arguments?.getBoolean(ARGS_IS_UNIV_SEARCH) ?: true
+ }
+
+ private fun initAdapter() {
+ if (isUnivSearch) {
+ binding.tvUnivSearchTitle.text = getString(R.string.onboarding_tv_search_school)
+ _univAdapter = UniversityAdapter(storeUniversity = ::storeUniversity)
+ binding.rvUnivSearchList.adapter = univAdapter
+ } else {
+ binding.tvUnivSearchTitle.text = getString(R.string.onboarding_tv_search_department)
+ _groupAdapter = DepartmentAdapter(storeDepartment = ::storeDepartment)
+ binding.rvUnivSearchList.adapter = groupAdapter
+ }
+ }
+
+ private fun storeUniversity(school: String) {
+ if (viewModel.school.value != school) {
+ viewModel.school.value = school
+ viewModel.subGroup.value = TEXT_NONE
+ viewModel.isChanged = true
+ }
+ dismiss()
+ }
+
+ private fun storeDepartment(department: String, groupId: Long) {
+ if (viewModel.groupId != groupId) {
+ viewModel.groupId = groupId
+ viewModel.subGroup.value = department
+ viewModel.isChanged = true
+ (activity as UnivProfileModActivity).checkIsTextNone()
+ }
+ dismiss()
+ }
+
+ private fun initSchoolFormBtnListener() {
+ if (isUnivSearch) {
+ setFormBtn(getString(R.string.onboarding_tv_add_school), SCHOOL_FORM_URL)
+ } else {
+ setFormBtn(getString(R.string.onboarding_btn_add_department), DEPARTMENT_FORM_URL)
+ }
+ }
+
+ private fun setFormBtn(btnText: String, uri: String) {
+ with(binding.btnUnivAddForm) {
+ text = btnText
+ setOnSingleClickListener {
+ Intent(Intent.ACTION_VIEW, Uri.parse(uri)).apply {
+ startActivity(this)
+ }
+ }
+ }
+ }
+
+ private fun initBackBtnListener() {
+ binding.btnBackDialog.setOnSingleClickListener { dismiss() }
+ }
+
+ private fun setDebounceSearch() {
+ binding.etUnivSearch.doAfterTextChanged { input ->
+ searchJob?.cancel()
+ searchJob = viewModel.viewModelScope.launch {
+ delay(debounceTime)
+ input.toString().let { text ->
+ searchText = text
+ getSearchListFromServer(searchText)
+ }
+ }
+ }
+ }
+
+ private fun setListWithInfinityScroll() {
+ binding.rvUnivSearchList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (dy > 0) {
+ recyclerView.layoutManager?.let { layoutManager ->
+ if (!binding.rvUnivSearchList.canScrollVertically(1) && layoutManager is LinearLayoutManager && layoutManager.findLastVisibleItemPosition() == univAdapter.itemCount - 1) {
+ getSearchListFromServer(searchText)
+ }
+ }
+ }
+ }
+ })
+ }
+
+ private fun getSearchListFromServer(searchText: String) {
+ if (isUnivSearch) {
+ viewModel.getUnivListFromServer(searchText)
+ } else {
+ viewModel.getUnivGroupIdListFromServer(searchText)
+ }
+ }
+
+ private fun setEmptyListWithTyping() {
+ binding.etUnivSearch.doOnTextChanged { _, _, _, _ ->
+ lifecycleScope.launch {
+ viewModel.setNewPage()
+ if (isUnivSearch) {
+ univAdapter.submitList(listOf())
+ univAdapter.notifyDataSetChanged()
+ } else {
+ groupAdapter.submitList(listOf())
+ groupAdapter.notifyDataSetChanged()
+ }
+ }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val dialog = BottomSheetDialog(requireContext(), theme)
+ dialog.setOnShowListener {
+ val bottomSheetDialog = it as BottomSheetDialog
+ val parentLayout =
+ bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet)
+ parentLayout?.let { view ->
+ val behaviour = BottomSheetBehavior.from(view)
+ setupFullHeight(view)
+ behaviour.state = BottomSheetBehavior.STATE_EXPANDED
+ }
+ }
+ return dialog
+ }
+
+ private fun observeGetUnivListState() {
+ viewModel.getUnivListState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> univAdapter.submitList(state.data.schoolList)
+
+ is UiState.Failure -> toast(getString(R.string.recommend_search_error))
+
+ is UiState.Loading -> return@onEach
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeGetUnivGroupIdListState() {
+ viewModel.getUnivGroupIdListState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> groupAdapter.submitList(state.data.groupList)
+
+ is UiState.Failure -> toast(getString(R.string.recommend_search_error))
+
+ is UiState.Loading -> return@onEach
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setupFullHeight(bottomSheet: View) {
+ val layoutParams = bottomSheet.layoutParams
+ layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT
+ bottomSheet.layoutParams = layoutParams
+ }
+
+ private fun setHideKeyboard() {
+ binding.layoutModSearchBottomSheet.setOnSingleClickListener {
+ requireContext().hideKeyboard(requireView())
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ _groupAdapter = null
+ _univAdapter = null
+ searchJob?.cancel()
+ viewModel.resetStateVariables()
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance(isUnivSearch: Boolean) = UnivModSearchBottomSheet().apply {
+ arguments = bundleOf(ARGS_IS_UNIV_SEARCH to isUnivSearch)
+ }
+
+ private const val ARGS_IS_UNIV_SEARCH = "IS_UNIV_SEARCH"
+
+ const val debounceTime = 500L
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivModSelectBottomSheet.kt b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivModSelectBottomSheet.kt
new file mode 100644
index 000000000..dd3054538
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivModSelectBottomSheet.kt
@@ -0,0 +1,55 @@
+package com.el.yello.presentation.main.profile.mod
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import com.el.yello.R
+import com.el.yello.databinding.FragmentUnivModSelectBottomSheetBinding
+import com.el.yello.presentation.onboarding.fragment.universityinfo.studentid.StudentIdDialogAdapter
+import com.example.ui.base.BindingBottomSheetDialog
+
+class UnivModSelectBottomSheet :
+ BindingBottomSheetDialog(R.layout.fragment_univ_mod_select_bottom_sheet) {
+
+ private val viewModel by activityViewModels()
+
+ private var _adapter: StudentIdDialogAdapter? = null
+ private val adapter
+ get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initAdapter()
+ setStudentIdList()
+ }
+
+ private fun initAdapter() {
+ _adapter = StudentIdDialogAdapter(storeStudentId = ::storeStudentId)
+ binding.rvSelectList.adapter = adapter
+
+ }
+
+ private fun storeStudentId(studentId: Int) {
+ if (viewModel.admYear.value != studentId.toString()) {
+ viewModel.admYear.value = studentId.toString()
+ viewModel.isChanged = true
+ }
+ dismiss()
+ }
+
+ private fun setStudentIdList() {
+ adapter.submitList(viewModel.studentIdList)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ _adapter = null
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance() = UnivModSelectBottomSheet()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivProfileModActivity.kt b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivProfileModActivity.kt
new file mode 100644
index 000000000..cf7557d45
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivProfileModActivity.kt
@@ -0,0 +1,141 @@
+package com.el.yello.presentation.main.profile.mod
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.core.view.isVisible
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.ActivityProfileUnivModBinding
+import com.el.yello.presentation.main.profile.mod.UnivProfileModViewModel.Companion.TEXT_NONE
+import com.example.ui.activity.navigateTo
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.drawableOf
+import com.example.ui.context.toast
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@AndroidEntryPoint
+class UnivProfileModActivity :
+ BindingActivity(R.layout.activity_profile_univ_mod) {
+
+ private val viewModel by viewModels()
+
+ private var univModSearchBottomSheet: UnivModSearchBottomSheet? = null
+ private var univModSelectBottomSheet: UnivModSelectBottomSheet? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ initUserData()
+ initChangeToSchoolBtnListener()
+ initSaveBtnListener()
+ initBackBtnListener()
+ initBottomSheetListener()
+ observeGetUserDataResult()
+ observeGetIsModValidResult()
+ observePostNewProfileResult()
+ }
+
+ private fun initUserData() {
+ binding.vm = viewModel
+ viewModel.getUserDataFromServer()
+ viewModel.getIsModValidFromServer()
+ }
+
+ private fun initChangeToSchoolBtnListener() {
+ binding.btnChangeUnivToSchool.setOnSingleClickListener {
+ navigateTo()
+ finish()
+ }
+ }
+
+ private fun initSaveBtnListener() {
+ binding.btnProfileModSave.setOnSingleClickListener {
+ when {
+ !viewModel.isChanged -> {
+ toast(getString(R.string.profile_mod_no_change))
+ }
+
+ !viewModel.isModAvailable -> {
+ toast(getString(R.string.profile_mod_no_valid))
+ }
+
+ viewModel.subGroup.value == TEXT_NONE -> {
+ binding.layoutProfileModSubgroup.background =
+ drawableOf(R.drawable.shape_red_fill_red500_line_10_rect)
+ binding.tvProfileModSubgroupError.isVisible = true
+ }
+
+ else -> {
+ viewModel.postNewProfileToServer()
+ }
+ }
+ }
+ }
+
+ private fun initBackBtnListener() {
+ binding.btnProfileModBack.setOnSingleClickListener { finish() }
+ }
+
+ private fun initBottomSheetListener() {
+ binding.btnSearchSchool.setOnSingleClickListener {
+ univModSearchBottomSheet = UnivModSearchBottomSheet.newInstance(true)
+ univModSearchBottomSheet?.show(supportFragmentManager, DIALOG_SCHOOL)
+ }
+ binding.btnSearchSubgroup.setOnSingleClickListener {
+ univModSearchBottomSheet = UnivModSearchBottomSheet.newInstance(false)
+ univModSearchBottomSheet?.show(supportFragmentManager, DIALOG_SUBGROUP)
+ }
+ binding.btnSearchYear.setOnSingleClickListener {
+ univModSelectBottomSheet = UnivModSelectBottomSheet.newInstance()
+ univModSelectBottomSheet?.show(supportFragmentManager, DIALOG_YEAR)
+ }
+ }
+
+ private fun observeGetUserDataResult() {
+ viewModel.getUserDataResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (!result) toast(getString(R.string.msg_error))
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeGetIsModValidResult() {
+ viewModel.getIsModValidResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (!result) toast(getString(R.string.msg_error))
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observePostNewProfileResult() {
+ viewModel.postToModProfileResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (result) {
+ toast(getString(R.string.profile_mod_success))
+ finish()
+ } else {
+ toast(getString(R.string.msg_error))
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ fun checkIsTextNone() {
+ if (viewModel.subGroup.value != TEXT_NONE) {
+ binding.layoutProfileModSubgroup.background =
+ drawableOf(R.drawable.shape_grayscales900_fill_12_rect)
+ binding.tvProfileModSubgroupError.isVisible = false
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (univModSearchBottomSheet != null) univModSearchBottomSheet?.dismiss()
+ if (univModSelectBottomSheet != null) univModSelectBottomSheet?.dismiss()
+ }
+
+ private companion object {
+ const val DIALOG_SCHOOL = "school"
+ const val DIALOG_SUBGROUP = "subgroup"
+ const val DIALOG_YEAR = "year"
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivProfileModViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivProfileModViewModel.kt
new file mode 100644
index 000000000..619b9eac7
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/profile/mod/UnivProfileModViewModel.kt
@@ -0,0 +1,187 @@
+package com.el.yello.presentation.main.profile.mod
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.presentation.main.profile.info.ProfileFragment.Companion.TYPE_UNIVERSITY
+import com.example.domain.entity.ProfileModRequestModel
+import com.example.domain.entity.onboarding.GroupList
+import com.example.domain.entity.onboarding.SchoolList
+import com.example.domain.repository.OnboardingRepository
+import com.example.domain.repository.ProfileRepository
+import com.example.ui.view.UiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.math.ceil
+
+@HiltViewModel
+class UnivProfileModViewModel @Inject constructor(
+ private val profileRepository: ProfileRepository,
+ private val onboardingRepository: OnboardingRepository
+) : ViewModel() {
+
+ private val _getUserDataResult = MutableSharedFlow()
+ val getUserDataResult: SharedFlow = _getUserDataResult
+
+ private val _getIsModValidResult = MutableSharedFlow()
+ val getIsModValidResult: SharedFlow = _getIsModValidResult
+
+ private val _postToModProfileResult = MutableSharedFlow()
+ val postToModProfileResult: SharedFlow = _postToModProfileResult
+
+ private val _getUnivListState = MutableStateFlow>(UiState.Empty)
+ val getUnivListState: StateFlow> = _getUnivListState
+
+ private val _getUnivGroupIdListState = MutableStateFlow>(UiState.Empty)
+ val getUnivGroupIdListState: StateFlow> = _getUnivGroupIdListState
+
+ val lastModDate = MutableLiveData("")
+ val school = MutableLiveData("")
+ val subGroup = MutableLiveData("")
+ val admYear = MutableLiveData("")
+ var groupId: Long = 0
+
+ var isModAvailable = true
+ var isChanged = false
+
+ private lateinit var myUserData: ProfileModRequestModel
+
+ val studentIdList = listOf(24, 23, 22, 21, 20, 19, 18, 17, 16, 15)
+
+ private var currentPage = -1
+ private var isPagingFinish = false
+ private var totalPage = Int.MAX_VALUE
+
+ fun setNewPage() {
+ currentPage = -1
+ isPagingFinish = false
+ totalPage = Int.MAX_VALUE
+ }
+
+ fun resetStateVariables() {
+ _getUnivListState.value = UiState.Empty
+ _getUnivGroupIdListState.value = UiState.Empty
+ }
+
+ fun getUserDataFromServer() {
+ viewModelScope.launch {
+ profileRepository.getUserData()
+ .onSuccess { profile ->
+ if (profile == null) {
+ _getUserDataResult.emit(false)
+ return@launch
+ }
+ if (profile.groupType == TYPE_UNIVERSITY) {
+ school.value = profile.groupName
+ subGroup.value = profile.subGroupName
+ admYear.value = profile.groupAdmissionYear.toString()
+ } else {
+ school.value = TEXT_NONE
+ subGroup.value = TEXT_NONE
+ admYear.value = TEXT_NONE
+ }
+ myUserData = ProfileModRequestModel(
+ profile.name,
+ profile.yelloId,
+ profile.gender,
+ profile.email,
+ profile.profileImageUrl,
+ profile.groupId,
+ admYear.value?.toInt() ?: 0
+ )
+ _getUserDataResult.emit(true)
+ }
+ .onFailure {
+ _getUserDataResult.emit(false)
+ }
+ }
+ }
+
+ fun getIsModValidFromServer() {
+ viewModelScope.launch {
+ profileRepository.getModValidData()
+ .onSuccess {
+ if (it == null) {
+ _getIsModValidResult.emit(false)
+ return@launch
+ }
+ val splitValue = it.value.split("|")
+ isModAvailable = splitValue[0].toBoolean()
+ lastModDate.value = splitValue[1].replace("-", ".")
+ }
+ .onFailure {
+ _getIsModValidResult.emit(false)
+ }
+ }
+ }
+
+ fun postNewProfileToServer() {
+ viewModelScope.launch {
+ if (!::myUserData.isInitialized) {
+ _postToModProfileResult.emit(false)
+ return@launch
+ }
+ profileRepository.postToModUserData(myUserData)
+ .onSuccess {
+ _postToModProfileResult.emit(true)
+ }
+ .onFailure {
+ _postToModProfileResult.emit(false)
+ }
+ }
+ }
+
+ fun getUnivListFromServer(searchText: String) {
+ if (isPagingFinish) return
+ viewModelScope.launch {
+ onboardingRepository.getSchoolList(
+ searchText,
+ ++currentPage
+ )
+ .onSuccess { schoolList ->
+ if (schoolList == null) {
+ _getUnivListState.value = UiState.Empty
+ return@launch
+ }
+ totalPage = ceil((schoolList.totalCount * 0.1)).toInt() - 1
+ if (totalPage == currentPage) isPagingFinish = true
+ _getUnivListState.value = UiState.Success(schoolList)
+ }
+ .onFailure { t ->
+ _getUnivListState.value = UiState.Failure(t.message.toString())
+ }
+ }
+ }
+
+ fun getUnivGroupIdListFromServer(searchText: String) {
+ viewModelScope.launch {
+ _getUnivGroupIdListState.value = UiState.Loading
+ onboardingRepository.getGroupList(
+ ++currentPage,
+ school.value ?: return@launch,
+ searchText,
+ )
+ .onSuccess { groupList ->
+ if (groupList == null || groupList.totalCount == 0) {
+ _getUnivGroupIdListState.value = UiState.Empty
+ return@launch
+ }
+ totalPage = ceil((groupList.totalCount * 0.1)).toInt() - 1
+ if (totalPage == currentPage) isPagingFinish = true
+ _getUnivGroupIdListState.value = UiState.Success(groupList)
+ }
+ .onFailure { t ->
+ _getUnivGroupIdListState.value = UiState.Failure(t.message.toString())
+ }
+ }
+ }
+
+ companion object {
+ const val TEXT_NONE = "-"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/RecommendFragment.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/RecommendFragment.kt
new file mode 100644
index 000000000..e4b4dbe1f
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/RecommendFragment.kt
@@ -0,0 +1,72 @@
+package com.el.yello.presentation.main.recommend
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.lifecycle.ViewModelProvider
+import com.el.yello.R
+import com.el.yello.databinding.FragmentRecommendBinding
+import com.el.yello.presentation.main.recommend.kakao.RecommendKakaoFragment
+import com.el.yello.presentation.main.recommend.kakao.RecommendKakaoViewModel
+import com.el.yello.presentation.main.recommend.school.RecommendSchoolFragment
+import com.el.yello.presentation.main.recommend.school.RecommendSchoolViewModel
+import com.el.yello.presentation.search.SearchActivity
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.setOnSingleClickListener
+import com.google.android.material.tabs.TabLayoutMediator
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class RecommendFragment : BindingFragment(R.layout.fragment_recommend) {
+
+ private lateinit var kakaoViewModel: RecommendKakaoViewModel
+ private lateinit var schoolViewModel: RecommendSchoolViewModel
+
+ private val tabTextList = listOf(TAB_KAKAO, TAB_SCHOOL)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ initViewModelProvider()
+ initSearchBtnListener()
+ setTabLayout()
+ }
+
+ private fun initViewModelProvider() {
+ kakaoViewModel = ViewModelProvider(requireActivity())[RecommendKakaoViewModel::class.java]
+ schoolViewModel = ViewModelProvider(requireActivity())[RecommendSchoolViewModel::class.java]
+ }
+
+ private fun initSearchBtnListener() {
+ binding.btnRecommendSearch.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties("click_search_button")
+ kakaoViewModel.isSearchViewShowed = true
+ schoolViewModel.isSearchViewShowed = true
+ Intent(activity, SearchActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ }
+ }
+
+ private fun setTabLayout() {
+ binding.vpRecommend.adapter = RecommendViewPagerAdapter(this)
+ TabLayoutMediator(binding.tabRecommend, binding.vpRecommend) { tab, pos ->
+ tab.text = tabTextList[pos]
+ }.attach()
+ }
+
+ fun scrollToTop() {
+ val currentFragment = childFragmentManager.fragments[binding.vpRecommend.currentItem]
+ if (currentFragment is RecommendKakaoFragment) {
+ currentFragment.scrollToTop()
+ } else if (currentFragment is RecommendSchoolFragment) {
+ currentFragment.scrollToTop()
+ }
+ }
+
+ private companion object {
+ const val TAB_KAKAO = "카톡 친구들"
+ const val TAB_SCHOOL = "학교 친구들"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/RecommendViewPagerAdapter.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/RecommendViewPagerAdapter.kt
new file mode 100644
index 000000000..a167ebcd7
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/RecommendViewPagerAdapter.kt
@@ -0,0 +1,18 @@
+package com.el.yello.presentation.main.recommend
+
+import androidx.fragment.app.Fragment
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import com.el.yello.presentation.main.recommend.kakao.RecommendKakaoFragment
+import com.el.yello.presentation.main.recommend.school.RecommendSchoolFragment
+
+class RecommendViewPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
+
+ override fun getItemCount(): Int = 2
+
+ override fun createFragment(position: Int): Fragment {
+ return when (position) {
+ 0 -> RecommendKakaoFragment()
+ else -> RecommendSchoolFragment()
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKaKaoViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKaKaoViewModel.kt
new file mode 100644
index 000000000..8de3ccd73
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKaKaoViewModel.kt
@@ -0,0 +1,141 @@
+package com.el.yello.presentation.main.recommend.kakao
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.presentation.main.recommend.list.RecommendViewHolder
+import com.example.domain.entity.RecommendListModel
+import com.example.domain.entity.RecommendRequestModel
+import com.example.domain.entity.RecommendUserInfoModel
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.RecommendRepository
+import com.example.ui.view.UiState
+import com.kakao.sdk.talk.TalkApiClient
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import javax.inject.Inject
+import kotlin.math.ceil
+
+@HiltViewModel
+class RecommendKakaoViewModel @Inject constructor(
+ private val recommendRepository: RecommendRepository,
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ private val _getKakaoErrorResult = MutableStateFlow(false)
+ val getKakaoErrorResult: StateFlow = _getKakaoErrorResult
+
+ private val _postFriendsListState = MutableStateFlow>(UiState.Empty)
+ val postFriendsListState: StateFlow> = _postFriendsListState
+
+ private val _addFriendState = MutableStateFlow>(UiState.Empty)
+ val addFriendState: StateFlow> = _addFriendState
+
+ private val _getUserDataState = MutableStateFlow>(UiState.Empty)
+ val getUserDataState: StateFlow> = _getUserDataState
+
+ var isSearchViewShowed = false
+
+ var itemPosition: Int? = null
+ var itemHolder: RecommendViewHolder? = null
+ var clickedUserData = RecommendUserInfoModel()
+
+ private var currentOffset = -100
+ private var currentPage = -1
+ private var isPagingFinish = false
+ private var totalPage = Int.MAX_VALUE
+
+ private var isFirstFriendsListPage: Boolean = true
+
+ fun setFirstPageLoading() {
+ isFirstFriendsListPage = true
+ }
+
+ fun setPositionAndHolder(position: Int, holder: RecommendViewHolder) {
+ itemPosition = position
+ itemHolder = holder
+ }
+
+ fun initViewModelVariable() {
+ currentOffset = -100
+ currentPage = -1
+ isPagingFinish = false
+ totalPage = Int.MAX_VALUE
+ _postFriendsListState.value = UiState.Empty
+ _addFriendState.value = UiState.Empty
+ _getKakaoErrorResult.value = false
+ }
+
+ fun getIdListFromKakao() {
+ if (isPagingFinish) return
+ currentOffset += 100
+ currentPage += 1
+ if (isFirstFriendsListPage) {
+ _postFriendsListState.value = UiState.Loading
+ isFirstFriendsListPage = false
+ }
+ TalkApiClient.instance.friends(offset = currentOffset, limit = 100) { friends, error ->
+ if (error != null) {
+ _getKakaoErrorResult.value = true
+ } else if (friends != null) {
+ totalPage = ceil((friends.totalCount * 0.01)).toInt() - 1
+ if (totalPage == currentPage) isPagingFinish = true
+ getFriendsListFromServer(
+ friends.elements?.map { friend -> friend.id.toString() } ?: listOf(),
+ )
+ }
+ }
+ }
+
+ private fun getFriendsListFromServer(friendsKakaoId: List) {
+ viewModelScope.launch {
+ recommendRepository.postToGetKakaoFriendList(
+ 0,
+ RecommendRequestModel(friendsKakaoId),
+ )
+ .onSuccess {
+ it ?: return@launch
+ _postFriendsListState.value = UiState.Success(it)
+ }
+ .onFailure {
+ _postFriendsListState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun addFriendToServer(friendId: Long) {
+ _addFriendState.value = UiState.Loading
+ viewModelScope.launch {
+ recommendRepository.postFriendAdd(friendId)
+ .onSuccess {
+ _addFriendState.value = UiState.Success(it)
+ }
+ .onFailure {
+ _addFriendState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun getUserDataFromServer(userId: Long) {
+ viewModelScope.launch {
+ _getUserDataState.value = UiState.Loading
+ recommendRepository.getRecommendUserInfo(userId)
+ .onSuccess { userInfo ->
+ if (userInfo == null) {
+ _getUserDataState.value = UiState.Empty
+ return@launch
+ }
+ _getUserDataState.value = UiState.Success(userInfo)
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ _getUserDataState.value = UiState.Failure(t.message.toString())
+ }
+ }
+ }
+ }
+
+ fun getYelloId() = authRepository.getYelloId()
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKakaoBottomSheet.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKakaoBottomSheet.kt
new file mode 100644
index 000000000..61e53a6f6
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKakaoBottomSheet.kt
@@ -0,0 +1,48 @@
+package com.el.yello.presentation.main.recommend.kakao
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentRecommendKakaoItemBottomSheetBinding
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.ui.base.BindingBottomSheetDialog
+import com.example.ui.view.setOnSingleClickListener
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class RecommendKakaoBottomSheet :
+ BindingBottomSheetDialog(R.layout.fragment_recommend_kakao_item_bottom_sheet) {
+
+ private val viewModel by activityViewModels()
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setBackgroundDrawableResource(R.color.transparent)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.vm = viewModel
+ setItemImage()
+ initAddBtnListener()
+ }
+
+ private fun setItemImage() {
+ binding.ivRecommendFriendThumbnail.setImageOrBasicThumbnail(viewModel.clickedUserData.profileImageUrl)
+ }
+
+ private fun initAddBtnListener() {
+ binding.btnRecommendFriendAdd.setOnSingleClickListener {
+ binding.btnRecommendFriendAdd.visibility = View.INVISIBLE
+ binding.btnRecommendItemAddPressed.visibility = View.VISIBLE
+ lifecycleScope.launch {
+ viewModel.addFriendToServer(viewModel.clickedUserData.userId)
+ delay(300)
+ dismiss()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKakaoFragment.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKakaoFragment.kt
new file mode 100644
index 000000000..230904854
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/kakao/RecommendKakaoFragment.kt
@@ -0,0 +1,325 @@
+package com.el.yello.presentation.main.recommend.kakao
+
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import android.view.animation.AnimationUtils
+import androidx.core.view.isVisible
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.FragmentRecommendKakaoBinding
+import com.el.yello.presentation.main.dialog.invite.InviteFriendDialog
+import com.el.yello.presentation.main.recommend.list.RecommendAdapter
+import com.el.yello.presentation.main.recommend.list.RecommendItemDecoration
+import com.el.yello.presentation.main.recommend.list.RecommendViewHolder
+import com.el.yello.presentation.util.BaseLinearRcvItemDeco
+import com.el.yello.util.Utils.setPullToScrollColor
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.domain.entity.RecommendListModel
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class RecommendKakaoFragment :
+ BindingFragment(R.layout.fragment_recommend_kakao) {
+
+ private var _adapter: RecommendAdapter? = null
+ private val adapter
+ get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ private lateinit var viewModel: RecommendKakaoViewModel
+
+ private var inviteYesFriendDialog: InviteFriendDialog? = null
+ private var inviteNoFriendDialog: InviteFriendDialog? = null
+ private var lastClickedRecommendModel: RecommendListModel.RecommendFriend? = null
+
+ private var recommendKakaoBottomSheet: RecommendKakaoBottomSheet? = null
+
+ private lateinit var itemDivider: RecommendItemDecoration
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initViewModel()
+ initInviteBtnListener()
+ initPullToScrollListener()
+ setKakaoRecommendList()
+ setAdapterWithClickListener()
+ observeUserDataState()
+ observeAddFriendsListState()
+ observeAddFriendState()
+ observeKakaoError()
+ setItemDecoration()
+ setInfinityScroll()
+ setDeleteAnimation()
+ AmplitudeUtils.trackEventWithProperties("view_recommend_kakao")
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (viewModel.isSearchViewShowed) {
+ adapter.clearList()
+ setKakaoRecommendList()
+ viewModel.isSearchViewShowed = false
+ }
+ }
+
+ private fun initViewModel() {
+ viewModel = ViewModelProvider(requireActivity())[RecommendKakaoViewModel::class.java]
+ viewModel.isSearchViewShowed = false
+ }
+
+ private fun setKakaoRecommendList() {
+ with(viewModel) {
+ setFirstPageLoading()
+ initViewModelVariable()
+ getIdListFromKakao()
+ }
+ }
+
+ private fun setInfinityScroll() {
+ binding.rvRecommendKakao.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (dy > 0) {
+ recyclerView.layoutManager?.let { layoutManager ->
+ if (!binding.rvRecommendKakao.canScrollVertically(1) && layoutManager is LinearLayoutManager && layoutManager.findLastVisibleItemPosition() == adapter.itemCount - 1) {
+ viewModel.getIdListFromKakao()
+ }
+ }
+ }
+ }
+ })
+ }
+
+ private fun initInviteBtnListener() {
+ binding.layoutInviteFriend.setOnSingleClickListener {
+ inviteYesFriendDialog =
+ InviteFriendDialog.newInstance(viewModel.getYelloId(), KAKAO_YES_FRIEND)
+ AmplitudeUtils.trackEventWithProperties(
+ "click_invite",
+ JSONObject().put("invite_view", KAKAO_YES_FRIEND),
+ )
+ inviteYesFriendDialog?.show(parentFragmentManager, INVITE_DIALOG)
+ }
+
+ binding.btnRecommendNoFriend.setOnSingleClickListener {
+ inviteNoFriendDialog =
+ InviteFriendDialog.newInstance(viewModel.getYelloId(), KAKAO_NO_FRIEND)
+ AmplitudeUtils.trackEventWithProperties(
+ "click_invite",
+ JSONObject().put("invite_view", KAKAO_NO_FRIEND),
+ )
+ inviteNoFriendDialog?.show(parentFragmentManager, INVITE_DIALOG)
+ }
+ }
+
+ private fun initPullToScrollListener() {
+ binding.layoutRecommendKakaoSwipe.apply {
+ setOnRefreshListener {
+ lifecycleScope.launch {
+ adapter.clearList()
+ setKakaoRecommendList()
+ delay(200)
+ binding.layoutRecommendKakaoSwipe.isRefreshing = false
+ }
+ }
+ setPullToScrollColor(R.color.grayscales_500, R.color.grayscales_700)
+ }
+ }
+
+ private fun setItemDecoration() {
+ itemDivider = RecommendItemDecoration(requireContext())
+ binding.rvRecommendKakao.addItemDecoration(itemDivider)
+ binding.rvRecommendKakao.addItemDecoration(BaseLinearRcvItemDeco(bottomPadding = 12))
+ }
+
+ private fun setAdapterWithClickListener() {
+ _adapter = RecommendAdapter(
+ buttonClick = { recommendModel, position, holder ->
+ viewModel.setPositionAndHolder(position, holder)
+ viewModel.addFriendToServer(recommendModel.id.toLong())
+ },
+
+ itemClick = { recommendModel, position, holder ->
+ viewModel.setPositionAndHolder(position, holder)
+ viewModel.getUserDataFromServer(recommendModel.id.toLong())
+ lastClickedRecommendModel = recommendModel
+ },
+ )
+ binding.rvRecommendKakao.adapter = adapter
+ }
+
+ private fun observeKakaoError() {
+ viewModel.getKakaoErrorResult.flowWithLifecycle(lifecycle).onEach { result ->
+ if (result) {
+ yelloSnackbar(requireView(), getString(R.string.recommend_error_friends_list))
+ showShimmerView(isLoading = true, hasList = true)
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeAddFriendsListState() {
+ viewModel.postFriendsListState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ startFadeIn()
+ if (state.data.friends.isEmpty() && adapter.itemCount == 0) {
+ showShimmerView(isLoading = false, hasList = false)
+ } else {
+ showShimmerView(isLoading = false, hasList = true)
+ adapter.addItemList(state.data.friends)
+ }
+ }
+
+ is UiState.Failure -> {
+ showShimmerView(isLoading = true, hasList = true)
+ yelloSnackbar(
+ requireView(),
+ getString(R.string.recommend_error_friend_connection),
+ )
+ }
+
+ is UiState.Loading -> showShimmerView(isLoading = true, hasList = true)
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeAddFriendState() {
+ viewModel.addFriendState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ val position = viewModel.itemPosition
+ val holder = viewModel.itemHolder
+ if (position != null && holder != null) {
+ removeItemWithAnimation(holder, position)
+ } else {
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
+ }
+ }
+
+ is UiState.Failure -> {
+ yelloSnackbar(
+ requireView(),
+ getString(R.string.recommend_error_add_friend_connection),
+ )
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
+ }
+
+ is UiState.Loading -> {
+ activity?.window?.setFlags(
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
+ )
+ }
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeUserDataState() {
+ viewModel.getUserDataState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ viewModel.clickedUserData = state.data.apply {
+ if (!this.yelloId.startsWith("@")) this.yelloId = "@" + this.yelloId
+ }
+ if (lastClickedRecommendModel != null) {
+ recommendKakaoBottomSheet = RecommendKakaoBottomSheet()
+ recommendKakaoBottomSheet?.show(parentFragmentManager, ITEM_BOTTOM_SHEET)
+ }
+ }
+
+ is UiState.Failure -> {
+ yelloSnackbar(requireView(), getString(R.string.profile_error_user_data))
+ }
+
+ is UiState.Empty -> return@onEach
+
+ is UiState.Loading -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setDeleteAnimation() {
+ binding.rvRecommendKakao.itemAnimator = object : DefaultItemAnimator() {
+ override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean {
+ holder.itemView.animation =
+ AnimationUtils.loadAnimation(holder.itemView.context, R.anim.slide_out_right)
+ return super.animateRemove(holder)
+ }
+ }
+ }
+
+ private fun removeItemWithAnimation(holder: RecommendViewHolder, position: Int) {
+ lifecycleScope.launch {
+ changeToCheckIcon(holder)
+ delay(300)
+ binding.rvRecommendKakao.removeItemDecoration(itemDivider)
+ adapter.removeItem(position)
+ delay(500)
+ binding.rvRecommendKakao.addItemDecoration(itemDivider)
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
+ if (adapter.itemCount == 0) {
+ showShimmerView(isLoading = false, hasList = false)
+ }
+ }
+ }
+
+ private fun changeToCheckIcon(holder: RecommendViewHolder) {
+ with(holder.binding) {
+ btnRecommendItemAdd.visibility = View.INVISIBLE
+ btnRecommendItemAddPressed.visibility = View.VISIBLE
+ }
+ }
+
+ private fun startFadeIn() {
+ val animation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in)
+ binding.rvRecommendKakao.startAnimation(animation)
+ }
+
+ private fun showShimmerView(isLoading: Boolean, hasList: Boolean) {
+ with(binding) {
+ if (isLoading) shimmerFriendList.startShimmer() else shimmerFriendList.stopShimmer()
+ layoutRecommendFriendsList.isVisible = hasList
+ layoutRecommendNoFriendsList.isVisible = !hasList
+ shimmerFriendList.isVisible = isLoading
+ rvRecommendKakao.isVisible = !isLoading
+ }
+ }
+
+ fun scrollToTop() {
+ binding.rvRecommendKakao.smoothScrollToPosition(0)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _adapter = null
+ if (inviteYesFriendDialog?.isAdded == true) inviteYesFriendDialog?.dismiss()
+ if (inviteNoFriendDialog?.isAdded == true) inviteNoFriendDialog?.dismiss()
+ if (recommendKakaoBottomSheet != null) recommendKakaoBottomSheet?.dismiss()
+ }
+
+ private companion object {
+ const val INVITE_DIALOG = "inviteDialog"
+ const val KAKAO_NO_FRIEND = "recommend_kakao_nofriend"
+ const val KAKAO_YES_FRIEND = "recommend_kakao_yesfriend"
+ const val ITEM_BOTTOM_SHEET = "itemBottomSheet"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendAdapter.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendAdapter.kt
new file mode 100644
index 000000000..988b67099
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendAdapter.kt
@@ -0,0 +1,54 @@
+package com.el.yello.presentation.main.recommend.list
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.databinding.ItemRecommendListBinding
+import com.example.domain.entity.RecommendListModel.RecommendFriend
+
+class RecommendAdapter(
+ private val buttonClick: (RecommendFriend, Int, RecommendViewHolder) -> (Unit),
+ private val itemClick: (RecommendFriend, Int, RecommendViewHolder) -> (Unit),
+) : RecyclerView.Adapter() {
+
+ private var itemList = mutableListOf()
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecommendViewHolder {
+ val inflater by lazy { LayoutInflater.from(parent.context) }
+ val binding: ItemRecommendListBinding =
+ ItemRecommendListBinding.inflate(inflater, parent, false)
+ return RecommendViewHolder(binding, buttonClick, itemClick)
+ }
+
+ override fun onBindViewHolder(holder: RecommendViewHolder, position: Int) {
+ changeToTextButton(holder)
+ holder.onBind(itemList[position], position)
+ }
+
+ override fun getItemCount(): Int = itemList.size
+
+ fun addItemList(newItems: List) {
+ this.itemList.addAll(newItems)
+ notifyDataSetChanged()
+ }
+
+ fun clearList() {
+ this.itemList.clear()
+ notifyDataSetChanged()
+ }
+
+ fun removeItem(position: Int) {
+ if (this.itemList.isNotEmpty()) {
+ this.itemList.removeAt(position)
+ notifyItemRemoved(position)
+ notifyItemRangeChanged(position, itemCount)
+ }
+ }
+
+ // 초기 아이템 텍스트 버튼으로 설정
+ private fun changeToTextButton(holder: RecommendViewHolder) {
+ holder.binding.btnRecommendItemAdd.visibility = View.VISIBLE
+ holder.binding.btnRecommendItemAddPressed.visibility = View.GONE
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendItemDecoration.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendItemDecoration.kt
new file mode 100644
index 000000000..c3cfa373d
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendItemDecoration.kt
@@ -0,0 +1,54 @@
+package com.el.yello.presentation.main.recommend.list
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Rect
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.example.ui.number.dpToPx
+
+class RecommendItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
+ private val dividerHeight = 1.dpToPx(context)
+ private val dividerMargin = 24.dpToPx(context)
+ private val dividerColor = ContextCompat.getColor(context, R.color.grayscales_800)
+ private val dividerPaint = Paint()
+
+ init {
+ dividerPaint.color = dividerColor
+ }
+
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State,
+ ) {
+ super.getItemOffsets(outRect, view, parent, state)
+ outRect.bottom = dividerHeight
+ }
+
+ override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
+ val left = parent.paddingLeft + dividerMargin
+ val right = parent.width - parent.paddingRight - dividerMargin
+
+ val childCount = parent.childCount
+ for (i in 0 until childCount - 1) {
+ val child = parent.getChildAt(i)
+ val params = child.layoutParams as RecyclerView.LayoutParams
+
+ val top = child.bottom + params.bottomMargin
+ val bottom = top + dividerHeight
+
+ c.drawRect(
+ left.toFloat(),
+ top.toFloat(),
+ right.toFloat(),
+ bottom.toFloat(),
+ dividerPaint,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendViewHolder.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendViewHolder.kt
new file mode 100644
index 000000000..f5c26ac63
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/list/RecommendViewHolder.kt
@@ -0,0 +1,31 @@
+package com.el.yello.presentation.main.recommend.list
+
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.databinding.ItemRecommendListBinding
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.domain.entity.RecommendListModel.RecommendFriend
+import com.example.ui.view.setOnSingleClickListener
+
+class RecommendViewHolder(
+ val binding: ItemRecommendListBinding,
+ private val buttonClick: (RecommendFriend, Int, RecommendViewHolder) -> Unit,
+ private val itemClick: (RecommendFriend, Int, RecommendViewHolder) -> (Unit),
+) : RecyclerView.ViewHolder(binding.root) {
+
+ fun onBind(item: RecommendFriend, position: Int) {
+ with(binding) {
+ tvRecommendItemName.text = item.name
+ tvRecommendItemSchool.text = item.group
+
+ ivRecommendItemThumbnail.setImageOrBasicThumbnail(item.profileImage.orEmpty())
+
+ btnRecommendItemAdd.setOnSingleClickListener {
+ buttonClick(item, position, this@RecommendViewHolder)
+ }
+
+ root.setOnSingleClickListener {
+ itemClick(item, position, this@RecommendViewHolder)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolBottomSheet.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolBottomSheet.kt
new file mode 100644
index 000000000..806c033db
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolBottomSheet.kt
@@ -0,0 +1,49 @@
+package com.el.yello.presentation.main.recommend.school
+
+import android.os.Bundle
+import android.view.View
+import androidx.core.view.isVisible
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentRecommendSchoolItemBottomSheetBinding
+import com.el.yello.util.Utils.setImageOrBasicThumbnail
+import com.example.ui.base.BindingBottomSheetDialog
+import com.example.ui.view.setOnSingleClickListener
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+class RecommendSchoolBottomSheet :
+ BindingBottomSheetDialog(R.layout.fragment_recommend_school_item_bottom_sheet) {
+
+ private val viewModel by activityViewModels()
+
+ override fun onStart() {
+ super.onStart()
+ dialog?.window?.setBackgroundDrawableResource(R.color.transparent)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.vm = viewModel
+ setItemImage()
+ initAddBtnListener()
+ }
+
+ private fun setItemImage() {
+ binding.ivRecommendFriendThumbnail.setImageOrBasicThumbnail(viewModel.clickedUserData.profileImageUrl)
+ }
+
+ private fun initAddBtnListener() {
+ binding.btnRecommendFriendAdd.setOnSingleClickListener {
+ binding.btnRecommendFriendAdd.isVisible = false
+ binding.btnRecommendItemAddPressed.isVisible = true
+ lifecycleScope.launch {
+ viewModel.addFriendToServer(viewModel.clickedUserData.userId)
+ delay(300)
+ dismiss()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolFragment.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolFragment.kt
new file mode 100644
index 000000000..d36fba80f
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolFragment.kt
@@ -0,0 +1,306 @@
+package com.el.yello.presentation.main.recommend.school
+
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import android.view.animation.AnimationUtils
+import androidx.core.view.isVisible
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.DefaultItemAnimator
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.FragmentRecommendSchoolBinding
+import com.el.yello.presentation.main.dialog.invite.InviteFriendDialog
+import com.el.yello.presentation.main.recommend.list.RecommendAdapter
+import com.el.yello.presentation.main.recommend.list.RecommendItemDecoration
+import com.el.yello.presentation.main.recommend.list.RecommendViewHolder
+import com.el.yello.presentation.util.BaseLinearRcvItemDeco
+import com.el.yello.util.Utils.setPullToScrollColor
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.domain.entity.RecommendListModel.RecommendFriend
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class RecommendSchoolFragment :
+ BindingFragment(R.layout.fragment_recommend_school) {
+
+ private var _adapter: RecommendAdapter? = null
+ private val adapter
+ get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ private lateinit var viewModel: RecommendSchoolViewModel
+
+ private var inviteYesFriendDialog: InviteFriendDialog? = null
+ private var inviteNoFriendDialog: InviteFriendDialog? = null
+ private var lastClickedRecommendModel: RecommendFriend? = null
+
+ private var recommendSchoolBottomSheet: RecommendSchoolBottomSheet? = null
+
+ private lateinit var friendsList: List
+
+ private lateinit var itemDivider: RecommendItemDecoration
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initViewModel()
+ initFirstList()
+ initInviteBtnListener()
+ initPullToScrollListener()
+ setItemDecoration()
+ setAdapterWithClickListener()
+ setListWithInfinityScroll()
+ observeAddFriendsListState()
+ observeAddFriendState()
+ observeUserDataState()
+ setDeleteAnimation()
+ AmplitudeUtils.trackEventWithProperties("view_recommend_school")
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (viewModel.isSearchViewShowed) {
+ adapter.clearList()
+ viewModel.setFirstPageLoading()
+ viewModel.getSchoolFriendsListFromServer()
+ viewModel.isSearchViewShowed = false
+ }
+ }
+
+ private fun initInviteBtnListener() {
+ binding.layoutInviteFriend.setOnSingleClickListener {
+ inviteYesFriendDialog =
+ InviteFriendDialog.newInstance(viewModel.getYelloId(), SCHOOL_YES_FRIEND)
+ AmplitudeUtils.trackEventWithProperties(
+ "click_invite",
+ JSONObject().put("invite_view", SCHOOL_YES_FRIEND),
+ )
+ inviteYesFriendDialog?.show(parentFragmentManager, INVITE_DIALOG)
+ }
+
+ binding.btnRecommendNoFriend.setOnSingleClickListener {
+ inviteNoFriendDialog =
+ InviteFriendDialog.newInstance(viewModel.getYelloId(), SCHOOL_NO_FRIEND)
+ AmplitudeUtils.trackEventWithProperties(
+ "click_invite",
+ JSONObject().put("invite_view", SCHOOL_NO_FRIEND),
+ )
+ inviteNoFriendDialog?.show(parentFragmentManager, INVITE_DIALOG)
+ }
+ }
+
+ private fun initViewModel() {
+ viewModel = ViewModelProvider(requireActivity())[RecommendSchoolViewModel::class.java]
+ viewModel.isSearchViewShowed = false
+ }
+
+ private fun initFirstList() {
+ viewModel.setFirstPageLoading()
+ viewModel.getSchoolFriendsListFromServer()
+ }
+
+ private fun initPullToScrollListener() {
+ binding.layoutRecommendSchoolSwipe.apply {
+ setOnRefreshListener {
+ lifecycleScope.launch {
+ adapter.clearList()
+ viewModel.setFirstPageLoading()
+ viewModel.getSchoolFriendsListFromServer()
+ delay(200)
+ binding.layoutRecommendSchoolSwipe.isRefreshing = false
+ }
+ }
+ setPullToScrollColor(R.color.grayscales_500, R.color.grayscales_700)
+ }
+ }
+
+ private fun setItemDecoration() {
+ itemDivider = RecommendItemDecoration(requireContext())
+ binding.rvRecommendSchool.addItemDecoration(itemDivider)
+ binding.rvRecommendSchool.addItemDecoration(BaseLinearRcvItemDeco(bottomPadding = 12))
+ }
+
+ private fun setAdapterWithClickListener() {
+ _adapter = RecommendAdapter(
+ buttonClick = { recommendModel, position, holder ->
+ viewModel.setPositionAndHolder(position, holder)
+ viewModel.addFriendToServer(recommendModel.id.toLong())
+ },
+ itemClick = { recommendModel, position, holder ->
+ viewModel.setPositionAndHolder(position, holder)
+ viewModel.getUserDataFromServer(recommendModel.id.toLong())
+ lastClickedRecommendModel = recommendModel
+ },
+ )
+ binding.rvRecommendSchool.adapter = adapter
+ }
+
+ private fun setListWithInfinityScroll() {
+ binding.rvRecommendSchool.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (dy > 0) {
+ recyclerView.layoutManager?.let { layoutManager ->
+ if (!binding.rvRecommendSchool.canScrollVertically(1) && layoutManager is LinearLayoutManager && layoutManager.findLastVisibleItemPosition() == adapter.itemCount - 1) {
+ viewModel.getSchoolFriendsListFromServer()
+ }
+ }
+ }
+ }
+ })
+ }
+
+ private fun observeAddFriendsListState() {
+ viewModel.postFriendsListState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ startFadeIn()
+ if (state.data.friends.isEmpty() && adapter.itemCount == 0) {
+ showShimmerView(isLoading = false, hasList = false)
+ } else {
+ showShimmerView(isLoading = false, hasList = true)
+ friendsList = state.data.friends
+ adapter.addItemList(friendsList)
+ }
+ }
+
+ is UiState.Failure -> {
+ showShimmerView(isLoading = true, hasList = true)
+ yelloSnackbar(
+ requireView(),
+ getString(R.string.recommend_error_school_friend_connection),
+ )
+ }
+
+ is UiState.Loading -> showShimmerView(isLoading = true, hasList = true)
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeAddFriendState() {
+ viewModel.addFriendState.flowWithLifecycle(lifecycle).onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ val position = viewModel.itemPosition
+ val holder = viewModel.itemHolder
+ if (position != null && holder != null) {
+ removeItemWithAnimation(holder, position)
+ } else {
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
+ }
+ }
+
+ is UiState.Failure -> {
+ yelloSnackbar(
+ requireView(),
+ getString(R.string.recommend_error_add_friend_connection),
+ )
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
+ }
+
+ is UiState.Loading -> {
+ activity?.window?.setFlags(
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
+ )
+ }
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun observeUserDataState() {
+ viewModel.getUserDataState.flowWithLifecycle(lifecycle).onEach { state ->
+ if (state is UiState.Success) {
+ viewModel.clickedUserData = state.data.apply {
+ if (!this.yelloId.startsWith("@")) this.yelloId = "@" + this.yelloId
+ }
+ if (lastClickedRecommendModel != null) {
+ recommendSchoolBottomSheet = RecommendSchoolBottomSheet()
+ recommendSchoolBottomSheet?.show(parentFragmentManager, ITEM_BOTTOM_SHEET)
+ }
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setDeleteAnimation() {
+ binding.rvRecommendSchool.itemAnimator = object : DefaultItemAnimator() {
+ override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean {
+ holder.itemView.animation =
+ AnimationUtils.loadAnimation(holder.itemView.context, R.anim.slide_out_right)
+ return super.animateRemove(holder)
+ }
+ }
+ }
+
+ private fun removeItemWithAnimation(holder: RecommendViewHolder, position: Int) {
+ lifecycleScope.launch {
+ changeToCheckIcon(holder)
+ delay(300)
+ binding.rvRecommendSchool.removeItemDecoration(itemDivider)
+ adapter.removeItem(position)
+ delay(500)
+ activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
+ binding.rvRecommendSchool.addItemDecoration(itemDivider)
+ if (adapter.itemCount == 0) {
+ showShimmerView(isLoading = false, hasList = false)
+ }
+ }
+ }
+
+ private fun changeToCheckIcon(holder: RecommendViewHolder) {
+ with(holder.binding) {
+ btnRecommendItemAdd.visibility = View.INVISIBLE
+ btnRecommendItemAddPressed.visibility = View.VISIBLE
+ }
+ }
+
+ private fun startFadeIn() {
+ val animation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in)
+ binding.rvRecommendSchool.startAnimation(animation)
+ }
+
+ private fun showShimmerView(isLoading: Boolean, hasList: Boolean) {
+ with(binding) {
+ if (isLoading) shimmerFriendList.startShimmer() else shimmerFriendList.stopShimmer()
+ layoutRecommendFriendsList.isVisible = hasList
+ layoutRecommendNoFriendsList.isVisible = !hasList
+ shimmerFriendList.isVisible = isLoading
+ rvRecommendSchool.isVisible = !isLoading
+ }
+ }
+
+ fun scrollToTop() {
+ binding.rvRecommendSchool.smoothScrollToPosition(0)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _adapter = null
+ if (inviteYesFriendDialog?.isAdded == true) inviteYesFriendDialog?.dismiss()
+ if (inviteNoFriendDialog?.isAdded == true) inviteNoFriendDialog?.dismiss()
+ if (recommendSchoolBottomSheet != null) recommendSchoolBottomSheet?.dismiss()
+ }
+
+ private companion object {
+ const val INVITE_DIALOG = "inviteDialog"
+ const val SCHOOL_NO_FRIEND = "recommend_school_nofriend"
+ const val SCHOOL_YES_FRIEND = "recommend_school_yesfriend"
+ const val ITEM_BOTTOM_SHEET = "itemBottomSheet"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolViewModel.kt
new file mode 100644
index 000000000..76949053a
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/recommend/school/RecommendSchoolViewModel.kt
@@ -0,0 +1,116 @@
+package com.el.yello.presentation.main.recommend.school
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.presentation.main.recommend.list.RecommendViewHolder
+import com.example.domain.entity.RecommendListModel
+import com.example.domain.entity.RecommendUserInfoModel
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.RecommendRepository
+import com.example.ui.view.UiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import javax.inject.Inject
+import kotlin.math.ceil
+
+@HiltViewModel
+class RecommendSchoolViewModel @Inject constructor(
+ private val recommendRepository: RecommendRepository,
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ private val _postFriendsListState = MutableStateFlow>(UiState.Empty)
+ val postFriendsListState: StateFlow> = _postFriendsListState
+
+ private val _addFriendState = MutableStateFlow>(UiState.Empty)
+ val addFriendState: StateFlow> = _addFriendState
+
+ private val _getUserDataState = MutableStateFlow>(UiState.Empty)
+ val getUserDataState: StateFlow> = _getUserDataState
+
+ var isSearchViewShowed = false
+
+ var itemPosition: Int? = null
+ var itemHolder: RecommendViewHolder? = null
+
+ var clickedUserData = RecommendUserInfoModel()
+
+ private var currentPage = -1
+ private var isPagingFinish = false
+ private var totalPage = Int.MAX_VALUE
+
+ private var isFirstFriendsListPage: Boolean = true
+
+ fun setFirstPageLoading() {
+ isFirstFriendsListPage = true
+ currentPage = -1
+ isPagingFinish = false
+ totalPage = Int.MAX_VALUE
+ _postFriendsListState.value = UiState.Empty
+ _addFriendState.value = UiState.Empty
+ }
+
+ fun setPositionAndHolder(position: Int, holder: RecommendViewHolder) {
+ itemPosition = position
+ itemHolder = holder
+ }
+
+ fun getSchoolFriendsListFromServer() {
+ viewModelScope.launch {
+ if (isPagingFinish) return@launch
+ if (isFirstFriendsListPage) {
+ _postFriendsListState.value = UiState.Loading
+ isFirstFriendsListPage = false
+ }
+ recommendRepository.getSchoolFriendList(
+ ++currentPage,
+ )
+ .onSuccess {
+ it ?: return@launch
+ totalPage = ceil((it.totalCount * 0.01)).toInt() - 1
+ if (totalPage == currentPage) isPagingFinish = true
+ _postFriendsListState.value = UiState.Success(it)
+ }
+ .onFailure {
+ _postFriendsListState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun addFriendToServer(friendId: Long) {
+ viewModelScope.launch {
+ _addFriendState.value = UiState.Loading
+ recommendRepository.postFriendAdd(friendId)
+ .onSuccess {
+ _addFriendState.value = UiState.Success(it)
+ }
+ .onFailure {
+ _addFriendState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun getUserDataFromServer(userId: Long) {
+ viewModelScope.launch {
+ _getUserDataState.value = UiState.Loading
+ recommendRepository.getRecommendUserInfo(userId)
+ .onSuccess { userInfo ->
+ if (userInfo == null) {
+ _getUserDataState.value = UiState.Empty
+ return@launch
+ }
+ _getUserDataState.value = UiState.Success(userInfo)
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ _getUserDataState.value = UiState.Failure(t.message.toString())
+ }
+ }
+ }
+ }
+
+ fun getYelloId() = authRepository.getYelloId()
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/YelloFragment.kt b/app/src/main/java/com/el/yello/presentation/main/yello/YelloFragment.kt
new file mode 100644
index 000000000..655ea0c8b
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/YelloFragment.kt
@@ -0,0 +1,107 @@
+package com.el.yello.presentation.main.yello
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.commit
+import androidx.fragment.app.replace
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentYelloBinding
+import com.el.yello.presentation.main.yello.YelloState.Lock
+import com.el.yello.presentation.main.yello.YelloState.Valid
+import com.el.yello.presentation.main.yello.YelloState.Wait
+import com.el.yello.presentation.main.yello.lock.YelloLockFragment
+import com.el.yello.presentation.main.yello.start.YelloStartFragment
+import com.el.yello.presentation.main.yello.vote.VoteActivity
+import com.el.yello.presentation.main.yello.wait.YelloWaitFragment
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState.Empty
+import com.example.ui.view.UiState.Failure
+import com.example.ui.view.UiState.Loading
+import com.example.ui.view.UiState.Success
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@AndroidEntryPoint
+class YelloFragment : BindingFragment(R.layout.fragment_yello) {
+ private val viewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setupYelloState()
+ checkStoredVote()
+ }
+
+ private fun setupYelloState() {
+ viewModel.yelloState.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach { state ->
+ when (state) {
+ is Loading -> {}
+ is Success -> {
+ when (state.data) {
+ is Lock -> navigateTo()
+ is Valid -> navigateTo()
+ is Wait -> navigateTo()
+ }
+ }
+
+ is Empty -> {
+ yelloSnackbar(
+ binding.root,
+ getString(R.string.msg_failure),
+ )
+ }
+
+ is Failure -> {
+ toast(getString(R.string.msg_auto_login_error))
+ restartApp(requireContext())
+ }
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private inline fun navigateTo() {
+ requireActivity().supportFragmentManager.commit {
+ replace(R.id.fcv_yello, T::class.java.canonicalName)
+ }
+ }
+
+ private fun restartApp(context: Context) {
+ val packageManager = context.packageManager
+ val packageName = context.packageName
+ val componentName = packageManager.getLaunchIntentForPackage(packageName)?.component
+ context.startActivity(Intent.makeRestartActivityTask(componentName))
+ Runtime.getRuntime().exit(0)
+ }
+
+ private fun checkStoredVote() {
+ viewModel.getStoredVote() ?: return
+ intentToVoteScreen()
+ }
+
+ private fun intentToVoteScreen() {
+ Intent(activity, VoteActivity::class.java).apply {
+ startActivity(this)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ viewModel.getVoteState()
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance() = YelloFragment()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/YelloState.kt b/app/src/main/java/com/el/yello/presentation/main/yello/YelloState.kt
new file mode 100644
index 000000000..7ec28e6fb
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/YelloState.kt
@@ -0,0 +1,7 @@
+package com.el.yello.presentation.main.yello
+
+sealed class YelloState {
+ object Lock : YelloState()
+ data class Valid(val point: Int, val hasFourFriends: Boolean) : YelloState()
+ data class Wait(val leftSec: Long) : YelloState()
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/YelloViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/yello/YelloViewModel.kt
new file mode 100644
index 000000000..d462e5d87
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/YelloViewModel.kt
@@ -0,0 +1,141 @@
+package com.el.yello.presentation.main.yello
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.presentation.main.yello.YelloState.Lock
+import com.el.yello.presentation.main.yello.YelloState.Valid
+import com.el.yello.presentation.main.yello.YelloState.Wait
+import com.example.domain.entity.PayInfoModel
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.PayRepository
+import com.example.domain.repository.VoteRepository
+import com.example.ui.view.UiState
+import com.example.ui.view.UiState.Empty
+import com.example.ui.view.UiState.Failure
+import com.example.ui.view.UiState.Success
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class YelloViewModel @Inject constructor(
+ private val voteRepository: VoteRepository,
+ private val authRepository: AuthRepository,
+ private val payRepository: PayRepository,
+) : ViewModel() {
+ private val _yelloState = MutableStateFlow>(UiState.Loading)
+ val yelloState: StateFlow>
+ get() = _yelloState.asStateFlow()
+
+ private val _leftTime = MutableStateFlow(0)
+ val leftTime: StateFlow
+ get() = _leftTime.asStateFlow()
+
+ private val _point = MutableStateFlow(0)
+ val point: StateFlow
+ get() = _point.asStateFlow()
+
+ private val _isDecreasing = MutableStateFlow(false)
+ private val isDecreasing: Boolean
+ get() = _isDecreasing.value
+
+ private val _getPurchaseInfoState =
+ MutableStateFlow>(UiState.Loading)
+ val getPurchaseInfoState: StateFlow> =
+ _getPurchaseInfoState.asStateFlow()
+
+ private fun decreaseTime() {
+ if (isDecreasing) return
+ viewModelScope.launch {
+ _isDecreasing.value = true
+ while (requireNotNull(leftTime.value) > 0) {
+ delay(1000L)
+ if (requireNotNull(leftTime.value) <= 0) return@launch
+ _leftTime.value = leftTime.value - 1
+ }
+
+ getVoteState()
+ _isDecreasing.value = false
+ }
+ }
+
+ fun getVoteState() {
+ viewModelScope.launch {
+ voteRepository.getVoteAvailable()
+ .onSuccess { voteState ->
+ if (voteState == null) {
+ _yelloState.value = Empty
+ return@launch
+ }
+
+ Timber.tag("GET_VOTE_STATE_SUCCESS").d(voteState.toString())
+ _point.value = voteState.point
+ if (voteState.isStart || voteState.leftTime !in 1..SEC_MAX_LOCK_TIME) {
+ val currentState = yelloState.value
+ if (currentState is Success) {
+ if (currentState.data is Valid) return@onSuccess
+ }
+ _yelloState.value =
+ Success(Valid(voteState.point, voteState.hasFourFriends))
+ return@launch
+ }
+
+ _yelloState.value = Success(Wait(voteState.leftTime))
+ _leftTime.value = voteState.leftTime
+ decreaseTime()
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("GET VOTE STATE FAILURE : $t")
+ when (t.code()) {
+ CODE_NO_FRIEND -> _yelloState.value = Success(Lock)
+ else -> {
+ authRepository.clearLocalPref()
+ delay(500)
+ _yelloState.value = Failure(t.code().toString())
+ }
+ }
+ }
+ Timber.e("GET VOTE STATE ERROR : $t")
+ }
+ }
+ }
+
+ fun getPurchaseInfoFromServer() {
+ viewModelScope.launch {
+ payRepository.getPurchaseInfo()
+ .onSuccess { purchaseInfo ->
+ if (purchaseInfo == null) {
+ _getPurchaseInfoState.value = Empty
+ return@onSuccess
+ }
+ _getPurchaseInfoState.value = Success(purchaseInfo)
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ _getPurchaseInfoState.value = Failure(t.code().toString())
+ }
+ }
+ }
+ }
+
+ fun getYelloId() = authRepository.getYelloId()
+
+ fun getStoredVote() = voteRepository.getStoredVote()
+
+ fun navigateToLockScreen() {
+ _yelloState.value = Success(Wait(SEC_MAX_LOCK_TIME))
+ }
+
+ companion object {
+ const val SEC_MAX_LOCK_TIME = 2400L
+
+ private const val CODE_NO_FRIEND = 400
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/lock/YelloLockFragment.kt b/app/src/main/java/com/el/yello/presentation/main/yello/lock/YelloLockFragment.kt
new file mode 100644
index 000000000..e4b9ef5e9
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/lock/YelloLockFragment.kt
@@ -0,0 +1,46 @@
+package com.el.yello.presentation.main.yello.lock
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import com.el.yello.R
+import com.el.yello.databinding.FragmentYelloLockBinding
+import com.el.yello.presentation.main.dialog.invite.InviteFriendDialog
+import com.el.yello.presentation.main.yello.YelloViewModel
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class YelloLockFragment : BindingFragment(R.layout.fragment_yello_lock) {
+ private val viewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initInviteBtnClickListener()
+ }
+
+ private fun initInviteBtnClickListener() {
+ binding.btnLockVote.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ EVENT_CLICK_INVITE,
+ JSONObject().put(JSON_INVITE_VIEW, VALUE_VOTE_4_DOWN),
+ )
+ InviteFriendDialog.newInstance(viewModel.getYelloId(), VALUE_VOTE_4_DOWN)
+ .show(parentFragmentManager, TAG_UNLOCK_DIALOG)
+ }
+ }
+
+ companion object {
+ const val TAG_UNLOCK_DIALOG = "UNLOCK_DIALOG"
+ const val EVENT_CLICK_INVITE = "click_invite"
+ const val JSON_INVITE_VIEW = "invite_view"
+ private const val VALUE_VOTE_4_DOWN = "vote_4down"
+
+ @JvmStatic
+ fun newInstance() = YelloLockFragment()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/point/PointFragment.kt b/app/src/main/java/com/el/yello/presentation/main/yello/point/PointFragment.kt
new file mode 100644
index 000000000..3f24dfe74
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/point/PointFragment.kt
@@ -0,0 +1,69 @@
+package com.el.yello.presentation.main.yello.point
+
+import android.os.Bundle
+import android.view.View
+import androidx.core.view.isVisible
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentPointBinding
+import com.el.yello.presentation.main.yello.YelloViewModel
+import com.el.yello.presentation.main.yello.vote.VoteViewModel
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+class PointFragment : BindingFragment(R.layout.fragment_point) {
+ private val yelloViewModel by activityViewModels()
+ private val voteViewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.vm = voteViewModel
+
+ setConfirmBtnClickListener()
+ observeCheckIsSubscribed()
+ yelloViewModel.getPurchaseInfoFromServer()
+ }
+
+ private fun setConfirmBtnClickListener() {
+ binding.btnPointConfirm.setOnSingleClickListener {
+ requireActivity().finish()
+ }
+ }
+
+ private fun observeCheckIsSubscribed() {
+ yelloViewModel.getPurchaseInfoState.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ binding.tvPointPlusLabel.isVisible = state.data.isSubscribe
+ if (state.data.isSubscribe) {
+ binding.tvPointVotePoint.text =
+ voteViewModel.votePointSum.times(2).toString()
+ }
+ }
+
+ is UiState.Failure -> {
+ binding.tvPointPlusLabel.visibility = View.GONE
+ binding.tvPointVotePoint.text = voteViewModel.votePointSum.toString()
+ }
+
+ is UiState.Loading -> {}
+
+ is UiState.Empty -> {}
+ }
+ binding.tvPointVotePoint.visibility = View.VISIBLE
+ binding.tvPointVotePointPlus.visibility = View.VISIBLE
+ binding.tvPointVotePointLabel.visibility = View.VISIBLE
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ companion object {
+ @JvmStatic
+ fun newInstance() = PointFragment()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/start/YelloStartFragment.kt b/app/src/main/java/com/el/yello/presentation/main/yello/start/YelloStartFragment.kt
new file mode 100644
index 000000000..9f4acdb89
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/start/YelloStartFragment.kt
@@ -0,0 +1,170 @@
+package com.el.yello.presentation.main.yello.start
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.graphics.Point
+import android.os.Bundle
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_UP
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentYelloStartBinding
+import com.el.yello.presentation.main.yello.YelloState
+import com.el.yello.presentation.main.yello.YelloViewModel
+import com.el.yello.presentation.main.yello.vote.VoteActivity
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingFragment
+import com.example.ui.context.setMargins
+import com.example.ui.fragment.getCompatibleRealSize
+import com.example.ui.view.UiState
+import com.example.ui.view.dpToPx
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+@AndroidEntryPoint
+class YelloStartFragment :
+ BindingFragment(R.layout.fragment_yello_start) {
+ private val viewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.vm = viewModel
+
+ setBalloonVisibility()
+ initEntranceLottie()
+ initShadowView()
+ initVoteBtnClickListener()
+ initVoteBtnTouchListener()
+ observeCheckIsSubscribed()
+ viewModel.getPurchaseInfoFromServer()
+ }
+
+ private fun setBalloonVisibility() {
+ val yelloState = viewModel.yelloState.value
+ if (yelloState is UiState.Success) {
+ if (yelloState.data is YelloState.Valid) {
+ binding.layoutStartBalloon.visibility =
+ if ((yelloState.data as YelloState.Valid).hasFourFriends) View.GONE else View.VISIBLE
+ }
+ }
+ }
+
+ private fun initEntranceLottie() {
+ with(binding.lottieStartEntrance) {
+ val size = Point()
+ getCompatibleRealSize(size)
+ val displayWidth = size.x
+ val displayHeight = size.y
+
+ layoutParams.width = (2.22 * displayWidth).toInt()
+ setMargins(this, 0, 0, 0, (-0.435 * displayHeight).toInt())
+ }
+ }
+
+ private fun initShadowView() {
+ binding.shadowStart.setLayerType(View.LAYER_TYPE_SOFTWARE, null)
+ val size = Point()
+ getCompatibleRealSize(size)
+ val displayWidth = size.x
+ setMargins(
+ view = binding.layoutSubsDouble,
+ left = 0,
+ top = displayWidth + MARGIN_TOP_SUBSCRIBE_LAYOUT.dpToPx(requireContext()),
+ right = 0,
+ bottom = 0,
+ )
+ }
+
+ private fun initVoteBtnClickListener() {
+ binding.btnStartVote.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(EVENT_CLICK_VOTE_START)
+ intentToVoteScreen()
+ }
+ }
+
+ // TODO : SuppressLint 제거
+ @SuppressLint("ClickableViewAccessibility")
+ private fun initVoteBtnTouchListener() {
+ binding.btnStartVote.setOnTouchListener { _, event ->
+ when (event.actionMasked) {
+ ACTION_DOWN -> {
+ with(binding.btnStartVote) {
+ background = ContextCompat.getDrawable(
+ context,
+ R.drawable.shape_yello_main_500_fill_100_rect,
+ )
+ setPadding(
+ 0,
+ PADDING_HORIZONTAL_VOTE_BTN_PRESSED.dpToPx(requireContext()),
+ 0,
+ PADDING_HORIZONTAL_VOTE_BTN_PRESSED.dpToPx(requireContext()),
+ )
+ }
+ }
+
+ ACTION_UP -> {
+ with(binding.btnStartVote) {
+ background = ContextCompat.getDrawable(
+ context,
+ R.drawable.shape_yello_main_500_fill_500_botshadow_rect,
+ )
+ setPadding(
+ 0,
+ PADDING_TOP_VOTE_BTN.dpToPx(requireContext()),
+ 0,
+ PADDING_BOTTOM_VOTE_BTN.dpToPx(requireContext()),
+ )
+ }
+ }
+ }
+ false
+ }
+ }
+
+ private fun intentToVoteScreen() {
+ Intent(activity, VoteActivity::class.java).apply {
+ startActivity(this)
+ }
+ }
+
+ private fun observeCheckIsSubscribed() {
+ viewModel.getPurchaseInfoState.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ if (state.data.isSubscribe) {
+ binding.layoutSubsDouble.visibility = View.VISIBLE
+ return@onEach
+ }
+ binding.layoutSubsDouble.visibility = View.GONE
+ }
+
+ is UiState.Failure -> {
+ binding.layoutSubsDouble.visibility = View.GONE
+ }
+
+ is UiState.Loading -> {}
+
+ is UiState.Empty -> {}
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ companion object {
+ private const val EVENT_CLICK_VOTE_START = "click_vote_start"
+
+ private const val MARGIN_TOP_SUBSCRIBE_LAYOUT = 16
+ private const val PADDING_HORIZONTAL_VOTE_BTN_PRESSED = 21
+ private const val PADDING_TOP_VOTE_BTN = 19
+ private const val PADDING_BOTTOM_VOTE_BTN = 23
+
+ @JvmStatic
+ fun newInstance() = YelloStartFragment()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/vote/NoteState.kt b/app/src/main/java/com/el/yello/presentation/main/yello/vote/NoteState.kt
new file mode 100644
index 000000000..358dbd3d9
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/vote/NoteState.kt
@@ -0,0 +1,10 @@
+package com.el.yello.presentation.main.yello.vote
+
+sealed class NoteState {
+ object Success : NoteState()
+ object InvalidSkip : NoteState()
+ object InvalidCancel : NoteState()
+ object InvalidShuffle : NoteState()
+ object InvalidName : NoteState()
+ object Failure : NoteState()
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/vote/VoteActivity.kt b/app/src/main/java/com/el/yello/presentation/main/yello/vote/VoteActivity.kt
new file mode 100644
index 000000000..eb29ad267
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/vote/VoteActivity.kt
@@ -0,0 +1,115 @@
+package com.el.yello.presentation.main.yello.vote
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.ActivityVoteBinding
+import com.el.yello.presentation.main.yello.vote.frame.NoteFrameAdapter
+import com.el.yello.presentation.main.yello.vote.note.NoteAdapter
+import com.el.yello.presentation.util.setCurrentItemWithDuration
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.toast
+import com.example.ui.transformation.FadeOutTransformation
+import com.example.ui.view.UiState
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class VoteActivity : BindingActivity(R.layout.activity_vote) {
+ private val viewModel by viewModels()
+
+ private val noteAdapter by lazy {
+ NoteAdapter(
+ fragmentActivity = this,
+ voteListSize = viewModel.totalListCount + 1,
+ )
+ }
+
+ private val noteFrameAdapter by lazy {
+ NoteFrameAdapter(
+ fragmentActivity = this,
+ bgIndex = viewModel.backgroundIndex,
+ voteListSize = viewModel.totalListCount + 1,
+ )
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.vm = viewModel
+
+ setupCurrentNoteIndex()
+ setupPostVoteState()
+ setupVoteState()
+ }
+
+ private fun setupVoteState() {
+ viewModel.voteState.flowWithLifecycle(lifecycle)
+ .onEach { state ->
+ when (state) {
+ is UiState.Loading -> {}
+ is UiState.Success -> initNoteViewPager()
+ is UiState.Empty -> {}
+ is UiState.Failure -> {
+ // TODO: 커스텀 스낵바로 변경
+ toast(getString(R.string.msg_error))
+ finish()
+ }
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun initNoteViewPager() {
+ with(binding.vpVoteNote) {
+ adapter = noteAdapter
+ isUserInputEnabled = false
+ }
+ with(binding.vpVoteNoteFrame) {
+ adapter = noteFrameAdapter
+ setPageTransformer(FadeOutTransformation())
+ isUserInputEnabled = false
+ }
+ }
+
+ private fun setupCurrentNoteIndex() {
+ viewModel._currentNoteIndex.flowWithLifecycle(lifecycle)
+ .onEach { index ->
+ binding.vpVoteNote.setCurrentItemWithDuration(index, DURATION_NOTE_TRANSITION)
+ binding.vpVoteNoteFrame.setCurrentItemWithDuration(index, DURATION_FRAME_TRANSITION)
+ if (index <= viewModel.totalListCount) {
+ val properties = JSONObject().put(JSON_VOTE_STEP, index + 1)
+ AmplitudeUtils.trackEventWithProperties(EVENT_VIEW_VOTE_QUESTION, properties)
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ private fun setupPostVoteState() {
+ viewModel.postVoteState.flowWithLifecycle(lifecycle)
+ .onEach { state ->
+ when (state) {
+ is UiState.Loading -> {}
+ is UiState.Failure -> {
+ yelloSnackbar(binding.root, getString(R.string.msg_error))
+ }
+
+ is UiState.Empty -> {
+ yelloSnackbar(binding.root, getString(R.string.msg_error))
+ }
+
+ is UiState.Success -> {}
+ }
+ }.launchIn(lifecycleScope)
+ }
+
+ companion object {
+ private const val DURATION_NOTE_TRANSITION = 500L
+ private const val DURATION_FRAME_TRANSITION = 400L
+ private const val JSON_VOTE_STEP = "vote_step"
+ private const val EVENT_VIEW_VOTE_QUESTION = "view_vote_question"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/vote/VoteViewModel.kt b/app/src/main/java/com/el/yello/presentation/main/yello/vote/VoteViewModel.kt
new file mode 100644
index 000000000..673ec8746
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/vote/VoteViewModel.kt
@@ -0,0 +1,324 @@
+package com.el.yello.presentation.main.yello.vote
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.el.yello.presentation.main.yello.vote.NoteState.InvalidCancel
+import com.el.yello.presentation.main.yello.vote.NoteState.InvalidName
+import com.el.yello.presentation.main.yello.vote.NoteState.InvalidShuffle
+import com.el.yello.presentation.main.yello.vote.NoteState.InvalidSkip
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.entity.vote.Choice
+import com.example.domain.entity.vote.ChoiceList
+import com.example.domain.entity.vote.Note
+import com.example.domain.entity.vote.StoredVote
+import com.example.domain.repository.VoteRepository
+import com.example.ui.view.UiState
+import com.example.ui.view.UiState.Empty
+import com.example.ui.view.UiState.Success
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import timber.log.Timber
+import javax.inject.Inject
+
+@HiltViewModel
+class VoteViewModel @Inject constructor(
+ private val voteRepository: VoteRepository,
+) : ViewModel() {
+ private val _noteState = MutableSharedFlow()
+ val noteState: SharedFlow get() = _noteState.asSharedFlow()
+
+ private val _voteState = MutableStateFlow>>(UiState.Loading)
+ val voteState: StateFlow>> get() = _voteState.asStateFlow()
+
+ private val _postVoteState = MutableStateFlow>(UiState.Loading)
+ val postVoteState: StateFlow> get() = _postVoteState.asStateFlow()
+
+ private val _shuffleCount = MutableStateFlow(MAX_COUNT_SHUFFLE)
+ val shuffleCount: StateFlow get() = _shuffleCount.asStateFlow()
+
+ private val _backgroundIndex = MutableStateFlow(0)
+ val backgroundIndex: Int get() = _backgroundIndex.value
+
+ val _currentNoteIndex = MutableStateFlow(0)
+ val currentNoteIndex: Int get() = _currentNoteIndex.value
+
+ val _currentChoice = MutableLiveData()
+ val currentChoice: Choice get() = requireNotNull(_currentChoice.value)
+
+ val _choiceList = MutableStateFlow(mutableListOf())
+ val choiceList: MutableList get() = requireNotNull(_choiceList.value)
+
+ val _voteList = MutableLiveData>()
+ val voteList: List get() = requireNotNull(_voteList.value)
+
+ private val _votePointSum = MutableStateFlow(0)
+ val votePointSum: Int get() = _votePointSum.value
+
+ private val _totalPoint = MutableStateFlow(0)
+ val totalPoint: Int get() = _totalPoint.value
+
+ private var isTransitioning = false
+
+ private val _noteHeight = MutableStateFlow(0)
+ val noteHeight: StateFlow get() = _noteHeight
+
+ var totalListCount = Int.MAX_VALUE
+ private set
+
+ init {
+ getStoredVote()
+ }
+
+ private fun getVoteQuestions() {
+ viewModelScope.launch {
+ voteRepository.getVoteQuestion()
+ .onSuccess { notes ->
+ Timber.d("GET VOTE QUESTION SUCCESS : $notes")
+ if (notes == null) {
+ _voteState.value = Empty
+ return@launch
+ }
+
+ for (noteIndex in notes.indices) {
+ val newFriendList = mutableListOf()
+ for (friendIndex in 0..3) {
+ if (notes[noteIndex].friendList.size < friendIndex + 1) {
+ newFriendList.add(Note.Friend(ID_EMPTY_USER, "", ""))
+ continue
+ }
+ newFriendList.add(notes[noteIndex].friendList[friendIndex])
+ }
+ notes[noteIndex].friendList = newFriendList
+ }
+
+ delay(400L)
+ totalListCount = notes.size - 1
+ _voteState.value = Success(notes)
+ _voteList.value = notes
+ initVoteIndex()
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("GET VOTE QUESTION FAILURE : $t")
+ _voteState.value = UiState.Failure(t.code().toString())
+ } else {
+ Timber.e("GET VOTE QUESTION ERROR : $t")
+ _voteState.value =
+ UiState.Failure(t.message.toString()) // TODO: 서버 수정 후 else 제거
+ }
+ }
+ }
+ }
+
+ fun selectName(nameIndex: Int) {
+ viewModelScope.launch {
+ if (currentNoteIndex > totalListCount) return@launch
+ if (voteList[currentNoteIndex].friendList[nameIndex].id == ID_EMPTY_USER) {
+ _noteState.emit(InvalidName)
+ return@launch
+ }
+ if (currentChoice.friendId == voteList[currentNoteIndex].friendList[nameIndex].id) {
+ _noteState.emit(InvalidCancel)
+ return@launch
+ }
+ if (currentChoice.friendId != null) return@launch
+ with(voteList[currentNoteIndex].friendList[nameIndex]) {
+ _currentChoice.value?.friendId = id
+ _currentChoice.value?.friendName = name
+ _currentChoice.value = _currentChoice.value
+ }
+
+ currentChoice.keywordName ?: return@launch
+ _choiceList.value.add(currentChoice)
+ _votePointSum.value = votePointSum + voteList[currentNoteIndex].point
+ viewModelScope.launch {
+ delay(DELAY_OPTION_SELECTION)
+ skipToNextVote()
+ }
+ }
+ }
+
+ fun selectKeyword(keywordIndex: Int) {
+ viewModelScope.launch {
+ if (currentNoteIndex > totalListCount) return@launch
+ if (currentChoice.keywordName == voteList[currentNoteIndex].keywordList[keywordIndex]) {
+ _noteState.emit(InvalidCancel)
+ return@launch
+ }
+ if (currentChoice.keywordName != null) return@launch
+ _currentChoice.value?.keywordName = voteList[currentNoteIndex].keywordList[keywordIndex]
+ _currentChoice.value = _currentChoice.value
+
+ currentChoice.friendId ?: return@launch
+ _choiceList.value.add(currentChoice)
+ _votePointSum.value = votePointSum + voteList[currentNoteIndex].point
+ viewModelScope.launch {
+ delay(DELAY_OPTION_SELECTION)
+ skipToNextVote()
+ }
+ }
+ }
+
+ fun shuffle() {
+ viewModelScope.launch {
+ shuffleCount.value.let { count ->
+ if (currentChoice.friendId != null) {
+ _noteState.emit(InvalidShuffle)
+ return@launch
+ }
+ if (count < 1) return@launch
+
+ voteRepository.getFriendShuffle()
+ .onSuccess { friends ->
+ Timber.d("GET FRIEND SHUFFLE SUCCESS : $friends")
+ if (friends == null) {
+ _noteState.emit(NoteState.Failure)
+ return@launch
+ }
+ val newFriendList = mutableListOf()
+ for (i in 0..3) {
+ if (friends.size < i + 1) {
+ newFriendList.add(Note.Friend(ID_EMPTY_USER, "", ""))
+ continue
+ }
+ newFriendList.add(friends[i])
+ }
+
+ _shuffleCount.value = count - 1
+ _voteList.value?.get(currentNoteIndex)?.friendList = newFriendList
+ _voteList.value = voteList
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("GET FRIEND SHUFFLE FAILURE : $t")
+ _noteState.emit(NoteState.Failure)
+ return@launch
+ }
+ }
+ }
+ }
+ }
+
+ fun skip() {
+ viewModelScope.launch {
+ if (isOptionSelected()) {
+ _noteState.emit(InvalidSkip)
+ return@launch
+ }
+
+ if (isTransitioning) return@launch
+
+ skipToNextVote()
+ }
+ }
+
+ private fun postVote() {
+ viewModelScope.launch {
+ voteRepository.postVote(
+ ChoiceList(
+ choiceList = choiceList,
+ totalPoint = votePointSum,
+ ),
+ )
+ .onSuccess { point ->
+ Timber.d("POST VOTE SUCCESS : $point")
+ if (point == null) {
+ _postVoteState.value = Empty
+ return@launch
+ }
+ voteRepository.clearStoredVote()
+ _postVoteState.value = Success(point)
+ _totalPoint.value = point
+ _currentNoteIndex.value = currentNoteIndex + 1
+ AmplitudeUtils.trackEventWithProperties("click_vote_finish")
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("POST VOTE FAILURE : $t")
+ voteRepository.clearStoredVote()
+ _noteState.emit(NoteState.Failure)
+ }
+ }
+ }
+ }
+
+ private fun initCurrentChoice() {
+ _currentChoice.value = Choice(
+ questionId = voteList[currentNoteIndex].questionId,
+ backgroundIndex = (backgroundIndex + currentNoteIndex) % 12 + 1,
+ )
+ }
+
+ private fun initVoteIndex() {
+ _backgroundIndex.value = (0..11).random()
+ _currentNoteIndex.value = 0
+ _votePointSum.value = 0
+ initCurrentChoice()
+ }
+
+ private fun skipToNextVote() {
+ viewModelScope.launch {
+ if (currentNoteIndex == totalListCount) {
+ postVote()
+ return@launch
+ }
+ isTransitioning = true
+ _noteState.emit(NoteState.Success)
+ _shuffleCount.value = MAX_COUNT_SHUFFLE
+ _currentNoteIndex.value = currentNoteIndex + 1
+ voteRepository.setStoredVote(
+ StoredVote(
+ currentIndex = currentNoteIndex,
+ choiceList = choiceList,
+ totalPoint = totalPoint,
+ questionList = voteList,
+ ),
+ )
+ initCurrentChoice()
+ viewModelScope.launch {
+ delay(DURATION_VIEW_SETTING)
+ isTransitioning = false
+ }
+ }
+ }
+
+ private fun getStoredVote() {
+ viewModelScope.launch {
+ val vote = voteRepository.getStoredVote()
+ if (vote == null) {
+ getVoteQuestions()
+ return@launch
+ }
+
+ Timber.tag("GET_STORED_VOTE_SUCCESS").d(vote.toString())
+ totalListCount = vote.questionList.size - 1
+ _voteList.value = vote.questionList
+ _voteState.value = Success(vote.questionList)
+ _choiceList.value = vote.choiceList
+ _totalPoint.value = vote.totalPoint
+ initCurrentChoice()
+ delay(DURATION_VIEW_SETTING)
+ _currentNoteIndex.value = vote.currentIndex
+ }
+ }
+
+ private fun isOptionSelected() = currentChoice.keywordName != null
+
+ companion object {
+ private const val MAX_COUNT_SHUFFLE = 3
+ private const val DELAY_OPTION_SELECTION = 1000L
+ private const val ID_EMPTY_USER = -1
+
+ // TODO: delay 없이 해결 가능한 방법 찾아보기
+ private const val DURATION_VIEW_SETTING = 500L
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/vote/frame/NoteFrameAdapter.kt b/app/src/main/java/com/el/yello/presentation/main/yello/vote/frame/NoteFrameAdapter.kt
new file mode 100644
index 000000000..324dc160d
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/vote/frame/NoteFrameAdapter.kt
@@ -0,0 +1,31 @@
+package com.el.yello.presentation.main.yello.vote.frame
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import com.el.yello.presentation.main.yello.point.PointFragment
+
+class NoteFrameAdapter(
+ fragmentActivity: FragmentActivity,
+ private val bgIndex: Int,
+ private val voteListSize: Int,
+) : FragmentStateAdapter(fragmentActivity) {
+ override fun getItemCount() = voteListSize + COUNT_POINT_FRAGMENT
+
+ override fun createFragment(position: Int): Fragment {
+ return when (position) {
+ in INDEX_START_POSITION until voteListSize -> NoteFrameFragment.newInstance(
+ index = position,
+ bgIndex = bgIndex,
+ voteListSize = voteListSize,
+ )
+
+ else -> PointFragment.newInstance()
+ }
+ }
+
+ companion object {
+ private const val INDEX_START_POSITION = 0
+ private const val COUNT_POINT_FRAGMENT = 1
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/vote/frame/NoteFrameFragment.kt b/app/src/main/java/com/el/yello/presentation/main/yello/vote/frame/NoteFrameFragment.kt
new file mode 100644
index 000000000..3a4a14ef3
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/vote/frame/NoteFrameFragment.kt
@@ -0,0 +1,189 @@
+package com.el.yello.presentation.main.yello.vote.frame
+
+import android.graphics.Point
+import android.os.Bundle
+import android.view.View
+import androidx.core.os.bundleOf
+import androidx.core.view.marginLeft
+import androidx.core.view.marginRight
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentNoteFrameBinding
+import com.el.yello.presentation.main.yello.vote.NoteState
+import com.el.yello.presentation.main.yello.vote.VoteViewModel
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingFragment
+import com.example.ui.context.setMargins
+import com.example.ui.fragment.getCompatibleRealSize
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.json.JSONObject
+import timber.log.Timber
+
+@AndroidEntryPoint
+class NoteFrameFragment : BindingFragment(R.layout.fragment_note_frame) {
+ private val viewModel by activityViewModels()
+
+ private var _noteIndex: Int? = null
+ private val noteIndex
+ get() = _noteIndex ?: 0
+
+ private var _backgroundIndex: Int? = null
+ private val backgroundIndex
+ get() = _backgroundIndex ?: 0
+
+ private var _voteListSize: Int? = null
+ private val voteListSize
+ get() = _voteListSize ?: 8
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.vm = viewModel
+
+ getBundleArgs()
+ addOvalProgressItems()
+ initShuffleBtnClickListener()
+ initSkipBtnClickListener()
+ }
+
+ private fun getBundleArgs() {
+ arguments ?: return
+ _noteIndex = arguments?.getInt(ARGS_NOTE_INDEX)
+ binding.index = noteIndex
+ _backgroundIndex = arguments?.getInt(ARGS_BACKGROUND_INDEX)?.plus(noteIndex)
+ binding.bgIndex = backgroundIndex
+ _voteListSize = arguments?.getInt(ARGS_VOTE_LIST_SIZE)
+ }
+
+ private fun addOvalProgressItems() {
+ for (i in 0 until noteIndex) {
+ layoutInflater.inflate(
+ R.layout.layout_vote_progress_bar,
+ binding.layoutNoteProgressBefore,
+ )
+ binding.layoutNoteProgressBefore.getChildAt(i).rotation = progressDegree[i]
+ }
+ for (i in noteIndex + 1 until voteListSize) {
+ layoutInflater.inflate(
+ R.layout.layout_vote_progress_bar,
+ binding.layoutNoteProgressAfter,
+ )
+ binding.layoutNoteProgressAfter.getChildAt(i - noteIndex - 1).rotation =
+ progressDegree[i]
+ }
+ }
+
+// private fun setupNoteHeight() {
+// viewModel.noteHeight.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+// .onEach { noteHeight ->
+// if (noteHeight == 0) return@onEach
+// Timber.tag("HEIGHT_TEST").d("note height : $noteHeight")
+//
+// val size = Point()
+// getCompatibleRealSize(size)
+// val displayHeight = size.y
+// Timber.tag("HEIGHT_TEST").d("display height : $displayHeight")
+//
+// with(binding) {
+// val componentHeight =
+// 20 + lottieNoteBalloon.height + ivNoteFace.height + noteHeight + flowVoteOption.height
+// Timber.tag("HEIGHT_TEST").d("component height : $componentHeight")
+// val spaceHeight = displayHeight - componentHeight
+// Timber.tag("HEIGHT_TEST").d("space height : $spaceHeight")
+// setMargins(lottieNoteBalloon, 0, spaceHeight / 2, 0, 0)
+// setMargins(flowVoteOption, flowVoteOption.marginLeft, 0, flowVoteOption.marginRight, spaceHeight / 2)
+// }
+// }.launchIn(viewLifecycleOwner.lifecycleScope)
+// }
+
+ private fun initShuffleBtnClickListener() {
+ binding.btnVoteShuffle.setOnSingleClickListener {
+ viewModel.shuffle()
+ if (noteIndex in 1..8) {
+ val properties = JSONObject().put(JSON_QUESTION_ID, noteIndex + 1)
+ AmplitudeUtils.trackEventWithProperties(EVENT_CLICK_VOTE_SHUFFLE, properties)
+ }
+ }
+ }
+
+ private fun initSkipBtnClickListener() {
+ binding.btnVoteSkip.setOnSingleClickListener {
+ viewModel.skip()
+ if (noteIndex in 1..8) {
+ val properties = JSONObject().put(JSON_QUESTION_ID, noteIndex + 1)
+ AmplitudeUtils.trackEventWithProperties(EVENT_CLICK_VOTE_SKIP, properties)
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ setupVoteState()
+ }
+
+ private fun setupVoteState() {
+ viewModel.noteState.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach { state ->
+ when (state) {
+ NoteState.Success -> return@onEach
+ NoteState.InvalidSkip -> yelloSnackbar(
+ binding.root,
+ getString(R.string.note_msg_invalid_skip),
+ )
+
+ NoteState.InvalidCancel -> yelloSnackbar(
+ binding.root,
+ getString(R.string.note_msg_invalid_cancel),
+ )
+
+ NoteState.InvalidShuffle -> yelloSnackbar(
+ binding.root,
+ getString(R.string.note_msg_invalid_shuffle),
+ )
+
+ NoteState.InvalidName -> yelloSnackbar(
+ binding.root,
+ getString(R.string.note_msg_invalid_name),
+ )
+
+ NoteState.Failure -> yelloSnackbar(
+ binding.root,
+ getString(R.string.msg_error),
+ )
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ companion object {
+ private const val ARGS_NOTE_INDEX = "NOTE_INDEX"
+ private const val ARGS_BACKGROUND_INDEX = "BACKGROUND_INDEX"
+ private const val ARGS_VOTE_LIST_SIZE = "VOTE_LIST_SIZE"
+
+ private const val JSON_QUESTION_ID = "question_id"
+
+ private const val EVENT_CLICK_VOTE_SHUFFLE = "click_vote_shuffle"
+ private const val EVENT_CLICK_VOTE_SKIP = "click_vote_skip"
+
+ private const val MARGIN_TOP_IV_FACE = 10
+ private const val HEIGHT_PROGRESS_BAR = 20
+
+ private val progressDegree =
+ listOf(165f, -30f, -120f, -165f, -60f, -20f, -117f, 24f, -45f, 12f)
+
+ @JvmStatic
+ fun newInstance(index: Int, bgIndex: Int, voteListSize: Int) = NoteFrameFragment().apply {
+ val args = bundleOf(
+ ARGS_NOTE_INDEX to index,
+ ARGS_BACKGROUND_INDEX to bgIndex,
+ ARGS_VOTE_LIST_SIZE to voteListSize,
+ )
+ arguments = args
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/vote/note/NoteAdapter.kt b/app/src/main/java/com/el/yello/presentation/main/yello/vote/note/NoteAdapter.kt
new file mode 100644
index 000000000..43c7247c9
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/vote/note/NoteAdapter.kt
@@ -0,0 +1,24 @@
+package com.el.yello.presentation.main.yello.vote.note
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+
+class NoteAdapter(
+ fragmentActivity: FragmentActivity,
+ private val voteListSize: Int,
+) : FragmentStateAdapter(fragmentActivity) {
+ override fun getItemCount() = voteListSize + COUNT_POINT_FRAGMENT
+
+ override fun createFragment(position: Int): Fragment {
+ return when (position) {
+ in INDEX_START_POSITION until voteListSize -> NoteFragment.newInstance(position)
+ else -> Fragment()
+ }
+ }
+
+ companion object {
+ private const val INDEX_START_POSITION = 0
+ private const val COUNT_POINT_FRAGMENT = 1
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/vote/note/NoteFragment.kt b/app/src/main/java/com/el/yello/presentation/main/yello/vote/note/NoteFragment.kt
new file mode 100644
index 000000000..87a68d0ea
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/vote/note/NoteFragment.kt
@@ -0,0 +1,46 @@
+package com.el.yello.presentation.main.yello.vote.note
+
+import android.os.Bundle
+import android.view.View
+import androidx.core.os.bundleOf
+import androidx.fragment.app.activityViewModels
+import com.el.yello.R
+import com.el.yello.databinding.FragmentNoteBinding
+import com.el.yello.presentation.main.yello.vote.VoteViewModel
+import com.example.ui.base.BindingFragment
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class NoteFragment : BindingFragment(R.layout.fragment_note) {
+ private val viewModel by activityViewModels()
+
+ private var _noteIndex: Int? = null
+ private val noteIndex
+ get() = _noteIndex ?: 0
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.vm = viewModel
+
+ getBundleArgs()
+ }
+
+ private fun getBundleArgs() {
+ arguments ?: return
+ _noteIndex = arguments?.getInt(ARGS_NOTE_INDEX)
+ binding.index = noteIndex
+ }
+
+ companion object {
+ // TODO : 자주 사용되는 상수들 파일로 빼기
+ private const val ARGS_NOTE_INDEX = "NOTE_INDEX"
+
+ @JvmStatic
+ fun newInstance(index: Int) = NoteFragment().apply {
+ val args = bundleOf(
+ ARGS_NOTE_INDEX to index,
+ )
+ arguments = args
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/main/yello/wait/YelloWaitFragment.kt b/app/src/main/java/com/el/yello/presentation/main/yello/wait/YelloWaitFragment.kt
new file mode 100644
index 000000000..38b1bf1a5
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/main/yello/wait/YelloWaitFragment.kt
@@ -0,0 +1,60 @@
+package com.el.yello.presentation.main.yello.wait
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import com.el.yello.R
+import com.el.yello.databinding.FragmentYelloWaitBinding
+import com.el.yello.presentation.main.dialog.invite.InviteFriendDialog
+import com.el.yello.presentation.main.yello.YelloViewModel
+import com.el.yello.presentation.main.yello.lock.YelloLockFragment.Companion.EVENT_CLICK_INVITE
+import com.el.yello.presentation.main.yello.lock.YelloLockFragment.Companion.JSON_INVITE_VIEW
+import com.el.yello.presentation.main.yello.lock.YelloLockFragment.Companion.TAG_UNLOCK_DIALOG
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingFragment
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class YelloWaitFragment : BindingFragment(R.layout.fragment_yello_wait) {
+ private val viewModel by activityViewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.vm = viewModel
+
+ initCircularProgressBar()
+ initInviteBtnClickListener()
+ }
+
+ private fun initCircularProgressBar() {
+ binding.cpbWaitTimer.progress = viewModel.leftTime.value.toFloat()
+ viewModel.leftTime.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach { time ->
+ binding.cpbWaitTimer.progress = time.toFloat()
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private fun initInviteBtnClickListener() {
+ binding.btnWaitInvite.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ EVENT_CLICK_INVITE,
+ JSONObject().put(JSON_INVITE_VIEW, VALUE_VOTE_40MIN_SCREEN),
+ )
+ InviteFriendDialog.newInstance(viewModel.getYelloId(), VALUE_VOTE_40MIN_SCREEN)
+ .show(parentFragmentManager, TAG_UNLOCK_DIALOG)
+ }
+ }
+
+ companion object {
+ const val VALUE_VOTE_40MIN_SCREEN = "vote_40min_reset"
+
+ @JvmStatic
+ fun newInstance() = YelloWaitFragment()
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/activity/EditNameActivity.kt b/app/src/main/java/com/el/yello/presentation/onboarding/activity/EditNameActivity.kt
new file mode 100644
index 000000000..3ac8f9521
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/activity/EditNameActivity.kt
@@ -0,0 +1,92 @@
+package com.el.yello.presentation.onboarding.activity
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.viewModels
+import com.el.yello.R
+import com.el.yello.databinding.ActivityNameEditBinding
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_EMAIL
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_GENDER
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_KAKAO_ID
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_NAME
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_PROFILE_IMAGE
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.toast
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class EditNameActivity :
+ BindingActivity(R.layout.activity_name_edit) {
+
+ private val viewModel by viewModels()
+
+ private var backPressedTime: Long = 0
+
+ private val onBackPressedCallback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (System.currentTimeMillis() - backPressedTime >= BACK_PRESSED_INTERVAL) {
+ backPressedTime = System.currentTimeMillis()
+ toast(getString(R.string.main_toast_back_pressed))
+ } else {
+ finish()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding.vm = viewModel
+ setConfirmBtnClickListener()
+ setDeleteBtnClickListener()
+ this.onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
+ }
+
+ private fun setConfirmBtnClickListener() {
+ getIntentExtraData()
+ binding.btnNameNext.setOnSingleClickListener {
+ val bundle = Bundle().apply {
+ putLong(EXTRA_KAKAO_ID, viewModel.kakaoId.toLong())
+ putString(EXTRA_NAME, viewModel.nameText.value.toString())
+ putString(EXTRA_GENDER, viewModel.gender)
+ putString(EXTRA_EMAIL, viewModel.email)
+ putString(EXTRA_PROFILE_IMAGE, viewModel.profileImg)
+ }
+ Intent(this, OnBoardingActivity::class.java).apply {
+ putExtras(bundle)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ finish()
+ }
+ }
+
+ private fun setDeleteBtnClickListener() {
+ binding.btnNameDelete.setOnSingleClickListener {
+ binding.etName.text.clear()
+ }
+ }
+
+ private fun getIntentExtraData() {
+ val bundle: Bundle? = intent.extras
+ intent.apply {
+ if (bundle != null) {
+ with(viewModel) {
+ kakaoId = bundle.getLong(EXTRA_KAKAO_ID, 0).toString()
+ nameText.value = bundle.getString(EXTRA_NAME, "")
+ gender = bundle.getString(EXTRA_GENDER, "")
+ email = bundle.getString(EXTRA_EMAIL, "")
+ profileImg = bundle.getString(EXTRA_PROFILE_IMAGE, "")
+ }
+ }
+ }
+ if (viewModel.nameText.value.isNullOrEmpty() || viewModel.nameText.value.isNullOrBlank()) {
+ binding.etName.text.clear()
+ }
+ }
+
+ companion object {
+ private const val BACK_PRESSED_INTERVAL = 2000
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/activity/GetAlarmActivity.kt b/app/src/main/java/com/el/yello/presentation/onboarding/activity/GetAlarmActivity.kt
new file mode 100644
index 000000000..e547a602c
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/activity/GetAlarmActivity.kt
@@ -0,0 +1,89 @@
+package com.el.yello.presentation.onboarding.activity
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.ContextCompat
+import com.el.yello.R
+import com.el.yello.databinding.ActivityGetAlarmBinding
+import com.el.yello.presentation.tutorial.TutorialAActivity
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.ui.base.BindingActivity
+import com.example.ui.intent.boolExtra
+import com.example.ui.view.setOnSingleClickListener
+
+class GetAlarmActivity :
+ BindingActivity(R.layout.activity_get_alarm) {
+
+ private val isFromOnBoarding by boolExtra()
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ askNotificationPermission()
+ }
+
+ private val requestPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
+ if (isGranted) {
+ AmplitudeUtils.updateUserProperties(EVENT_PUSH_NOTIFICATION, VALUE_ENABLED)
+ startTutorialActivity()
+ } else {
+ AmplitudeUtils.updateUserProperties(EVENT_PUSH_NOTIFICATION, VALUE_DISABLED)
+ startTutorialActivity()
+ }
+ }
+
+ private fun startTutorialActivity() {
+ val isCodeTextEmpty =
+ intent.getBooleanExtra(OnBoardingActivity.EXTRA_CODE_TEXT_EMPTY, false)
+ val intent = TutorialAActivity.newIntent(this, false).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ putExtra(OnBoardingActivity.EXTRA_CODE_TEXT_EMPTY, isCodeTextEmpty)
+ putExtra(TutorialAActivity.EXTRA_FROM_ONBOARDING, isFromOnBoarding)
+ }
+ startActivity(intent)
+ finish()
+ }
+
+ private fun askNotificationPermission() {
+ binding.btnStartYello.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(EVENT_CLICK_ONBOARDING_NOTIFICATION)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
+ ) {
+ startTutorialActivity()
+ } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
+ requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ } else {
+ startTutorialActivity()
+ }
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION)
+ }
+
+ companion object {
+ @JvmStatic
+ fun newIntent(context: Context, isFromOnBoarding: Boolean) =
+ Intent(context, GetAlarmActivity::class.java).apply {
+ putExtra("isFromOnBoarding", isFromOnBoarding)
+ }
+ private const val NONE_ANIMATION = 0
+ private const val EVENT_PUSH_NOTIFICATION = "user_pushnotification"
+ private const val VALUE_ENABLED = "enabled"
+ private const val VALUE_DISABLED = "disabled"
+ private const val EVENT_CLICK_ONBOARDING_NOTIFICATION = "click_onboarding_notification"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/activity/OnBoardingActivity.kt b/app/src/main/java/com/el/yello/presentation/onboarding/activity/OnBoardingActivity.kt
new file mode 100644
index 000000000..ad6d981a3
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/activity/OnBoardingActivity.kt
@@ -0,0 +1,145 @@
+package com.el.yello.presentation.onboarding.activity
+
+import android.animation.ObjectAnimator
+import android.os.Bundle
+import android.view.View
+import android.view.animation.LinearInterpolator
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.viewModels
+import androidx.navigation.findNavController
+import com.el.yello.R
+import com.el.yello.databinding.ActivityOnboardingBinding
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_EMAIL
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_GENDER
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_KAKAO_ID
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_NAME
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_PROFILE_IMAGE
+import com.example.ui.base.BindingActivity
+import com.example.ui.context.toast
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class OnBoardingActivity :
+ BindingActivity(R.layout.activity_onboarding) {
+
+ private val viewModel by viewModels()
+
+ private var backPressedTime: Long = 0
+
+ private val onBackPressedCallback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ val navController = findNavController(R.id.nav_main_fragment)
+ when (navController.currentDestination?.id) {
+ R.id.selectStudentFragment -> {
+ if (System.currentTimeMillis() - backPressedTime >= BACK_PRESSED_INTERVAL) {
+ backPressedTime = System.currentTimeMillis()
+ toast(getString(R.string.main_toast_back_pressed))
+ } else {
+ finish()
+ }
+ }
+
+ R.id.codeFragment -> {
+ if (System.currentTimeMillis() - backPressedTime >= BACK_PRESSED_INTERVAL) {
+ backPressedTime = System.currentTimeMillis()
+ toast(getString(R.string.main_toast_back_pressed))
+ } else {
+ finish()
+ }
+ }
+
+ else -> {
+ navController.popBackStack()
+ progressBarMinus()
+ }
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ getIntentExtraData()
+ this.onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
+ }
+
+ fun onBackButtonClicked(view: View) {
+ val navController = findNavController(R.id.nav_main_fragment)
+ when (navController.currentDestination?.id) {
+ R.id.selectStudentFragment -> {
+ }
+
+ else -> {
+ navController.popBackStack()
+ progressBarMinus()
+ }
+ }
+ }
+
+ private fun getIntentExtraData() {
+ val bundle: Bundle? = intent.extras
+ intent.apply {
+ if (bundle != null) {
+ with(viewModel) {
+ kakaoId = bundle.getLong(EXTRA_KAKAO_ID, 0).toString()
+ nameText.value = bundle.getString(EXTRA_NAME, "")
+ gender = bundle.getString(EXTRA_GENDER, "")
+ email = bundle.getString(EXTRA_EMAIL, "")
+ profileImg = bundle.getString(EXTRA_PROFILE_IMAGE, "")
+ }
+ }
+ }
+ }
+
+ fun endTutorialActivity() {
+ val intent = GetAlarmActivity.newIntent(this, true)
+ intent.putExtra(EXTRA_CODE_TEXT_EMPTY, viewModel.isCodeTextEmpty())
+ startActivity(intent)
+ finish()
+ }
+
+ fun progressBarPlus() {
+ viewModel.plusCurrentPercent()
+ val animator = ObjectAnimator.ofInt(
+ binding.onboardingProgressbar,
+ PROPERTY_PROGRESS,
+ binding.onboardingProgressbar.progress,
+ viewModel.currentPercent,
+ )
+ animator.duration = 300
+ animator.interpolator = LinearInterpolator()
+ animator.start()
+ }
+
+ fun progressBarMinus() {
+ viewModel.minusCurrentPercent()
+ val animator = ObjectAnimator.ofInt(
+ binding.onboardingProgressbar,
+ PROPERTY_PROGRESS,
+ binding.onboardingProgressbar.progress,
+ viewModel.currentPercent,
+ )
+ animator.duration = 300
+ animator.interpolator = LinearInterpolator()
+ animator.start()
+ }
+
+ fun hideBackBtn() {
+ binding.backBtn.visibility = View.INVISIBLE
+ }
+
+ fun showBackBtn() {
+ binding.backBtn.visibility = View.VISIBLE
+ }
+
+ override fun onPause() {
+ super.onPause()
+ overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION)
+ }
+
+ companion object {
+ private const val BACK_PRESSED_INTERVAL = 2000
+ private const val NONE_ANIMATION = 0
+ const val EXTRA_CODE_TEXT_EMPTY = "codeTextEmpty"
+ const val PROPERTY_PROGRESS = "progress"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/activity/OnBoardingViewModel.kt b/app/src/main/java/com/el/yello/presentation/onboarding/activity/OnBoardingViewModel.kt
new file mode 100644
index 000000000..5492ed473
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/activity/OnBoardingViewModel.kt
@@ -0,0 +1,400 @@
+package com.el.yello.presentation.onboarding.activity
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.map
+import androidx.lifecycle.viewModelScope
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.entity.onboarding.AddFriendListModel.FriendModel
+import com.example.domain.entity.onboarding.GroupHighSchool
+import com.example.domain.entity.onboarding.GroupList
+import com.example.domain.entity.onboarding.HighSchoolList
+import com.example.domain.entity.onboarding.RequestAddFriendModel
+import com.example.domain.entity.onboarding.SchoolList
+import com.example.domain.entity.onboarding.SignupInfo
+import com.example.domain.entity.onboarding.UserInfo
+import com.example.domain.repository.AuthRepository
+import com.example.domain.repository.OnboardingRepository
+import com.example.ui.view.UiState
+import com.kakao.sdk.talk.TalkApiClient
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import retrofit2.HttpException
+import timber.log.Timber
+import java.util.regex.Pattern
+import javax.inject.Inject
+import kotlin.math.ceil
+
+@HiltViewModel
+class OnBoardingViewModel @Inject constructor(
+ private val onboardingRepository: OnboardingRepository,
+ private val authRepository: AuthRepository,
+) : ViewModel() {
+
+ var currentPercent = 17
+ val nameText = MutableLiveData("")
+ val isValidName: LiveData = nameText.map { name -> checkName(name) }
+
+ val checkNameLength: LiveData = nameText.map { name ->
+ (name?.trim()?.length ?: 0) >= 2
+ }
+
+ val studentType = MutableLiveData("")
+ val university: String get() = universityText.value?.trim() ?: ""
+ val universityText = MutableLiveData("")
+ val highSchool: String get() = highSchoolText.value?.trim() ?: ""
+ val highSchoolText = MutableLiveData("")
+
+ val departmentText = MutableLiveData("")
+ val highSchoolGroupText = MutableLiveData()
+ private val _groupId = MutableLiveData()
+ val groupId: Long get() = requireNotNull(_groupId.value)
+
+ val studentIdText = MutableLiveData()
+ val studentId: Int get() = requireNotNull(studentIdText.value)
+
+ val idText = MutableLiveData("")
+ val id: String get() = idText.value?.trim() ?: ""
+ val isValidId: LiveData = idText.map { id -> checkId(id) }
+ val codeText = MutableLiveData("")
+ val isValidCode: LiveData = codeText.map { id -> checkId(id) }
+
+ private var currentFriendOffset = -100
+ private var currentFriendPage = -1
+ private var isFriendPagingFinish = false
+ private var totalFriendPage = Int.MAX_VALUE
+ private var isFirstFriendsListPage: Boolean = true
+
+ var kakaoId: String = ""
+ var email: String = ""
+ var profileImg: String = ""
+ var gender: String = ""
+ private val _universityState = MutableStateFlow>(UiState.Empty)
+ val universityState: StateFlow> = _universityState
+
+ private val _highSchoolState = MutableStateFlow>(UiState.Empty)
+ val highSchoolState: StateFlow> = _highSchoolState
+
+ private val _departmentState = MutableStateFlow>(UiState.Empty)
+ val departmentState: StateFlow> = _departmentState
+
+ private val _highSchoolGroupState = MutableLiveData>()
+
+ private val _highSchoolGroupList: MutableLiveData> = MutableLiveData()
+ val highSchoolGroupList: LiveData> = _highSchoolGroupList
+
+ private val _studentIdResult: MutableLiveData> = MutableLiveData()
+ val studentIdResult: LiveData> = _studentIdResult
+
+ private val _friendListState = MutableStateFlow>>(UiState.Empty)
+ val friendListState: StateFlow>> = _friendListState
+
+ var selectedFriendIdList: List = listOf()
+ var selectedFriendCount: MutableLiveData = MutableLiveData(0)
+ private val totalFriendList = mutableListOf()
+
+ private val _getValidYelloIdState = MutableLiveData>()
+ val getValidYelloIdState: LiveData> get() = _getValidYelloIdState
+
+ private val _postSignupState = MutableLiveData>()
+ val postSignupState: LiveData> get() = _postSignupState
+
+ fun plusCurrentPercent() {
+ currentPercent += 17
+ }
+
+ fun minusCurrentPercent() {
+ currentPercent -= 17
+ }
+
+ fun resetGetValidYelloId() {
+ _getValidYelloIdState.value = UiState.Loading
+ }
+
+ fun selectStudentType(student: String) {
+ studentType.value = student
+ }
+
+ fun setUniversity(university: String) {
+ universityText.value = university
+ }
+
+ fun clearUniversityData() {
+ _universityState.value = UiState.Success(SchoolList(0, emptyList()))
+ }
+
+ fun setHighSchool(highSchool: String) {
+ highSchoolText.value = highSchool
+ }
+
+ fun clearHighSchoolData() {
+ _highSchoolState.value = UiState.Success(HighSchoolList(0, emptyList()))
+ }
+
+ fun clearDepartmentData() {
+ _departmentState.value = UiState.Success(GroupList(0, emptyList()))
+ }
+
+ fun setGroupUniversityInfo(department: String, groupId: Long) {
+ departmentText.value = department
+ _groupId.value = groupId
+ }
+
+ fun setGroupHighSchoolInfo(group: String) {
+ highSchoolGroupText.value = group
+ getHighSchoolGroupId(group)
+ }
+
+ fun addHighSchoolGroup() {
+ val highSchoolGroupList = listOf(
+ "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20",
+ )
+ _highSchoolGroupList.value = highSchoolGroupList
+ }
+
+ fun setStudentId(studentId: Int) {
+ studentIdText.value = studentId
+ }
+
+ fun addUniversityStudentId() {
+ val studentIdList = listOf(24, 23, 22, 21, 20, 19, 18, 17, 16, 15)
+ _studentIdResult.value = studentIdList
+ }
+
+ fun selectGrade(grade: Int) {
+ studentIdText.value = grade
+ }
+
+ private fun checkName(name: String) = Pattern.matches(REGEX_NAME_PATTERN, name)
+ private fun checkId(id: String) = Pattern.matches(REGEX_ID_PATTERN, id)
+
+ fun isCodeTextEmpty() = codeText.value.isNullOrEmpty()
+
+ fun initFriendPagingVariable() {
+ selectedFriendCount.value = 0
+ totalFriendList.clear()
+ isFirstFriendsListPage = true
+ currentFriendOffset = -100
+ currentFriendPage = -1
+ isFriendPagingFinish = false
+ totalFriendPage = Int.MAX_VALUE
+ }
+
+ fun getUniversityList(search: String) {
+ viewModelScope.launch {
+ _universityState.value = UiState.Loading
+ onboardingRepository.getSchoolList(
+ search,
+ 0,
+ )
+ .onSuccess { schoolList ->
+ Timber.d("GET SCHOOL LIST SUCCESS : $schoolList")
+ if (schoolList == null) {
+ _universityState.value = UiState.Empty
+ return@launch
+ }
+ _universityState.value = when {
+ schoolList.schoolList.isEmpty() -> UiState.Empty
+ else -> UiState.Success(schoolList)
+ }
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("GET SCHOOL LIST FAILURE : $t")
+ _universityState.value = UiState.Failure(t.code().toString())
+ }
+ }
+ }
+ }
+
+ fun getHighSchoolList(search: String) {
+ viewModelScope.launch {
+ _highSchoolState.value = UiState.Loading
+ onboardingRepository.getHighSchoolList(
+ search,
+ 0,
+ )
+ .onSuccess { highSchoolList ->
+ Timber.d("GET SCHOOL LIST SUCCESS : $highSchoolList")
+ if (highSchoolList == null) {
+ _highSchoolState.value = UiState.Empty
+ return@launch
+ }
+ _highSchoolState.value = when {
+ highSchoolList.groupNameList.isEmpty() -> UiState.Empty
+ else -> UiState.Success(highSchoolList)
+ }
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("GET SCHOOL LIST FAILURE : $t")
+ _highSchoolState.value = UiState.Failure(t.code().toString())
+ }
+ }
+ }
+ }
+
+ fun getUniversityGroupId(search: String) {
+ viewModelScope.launch {
+ _departmentState.value = UiState.Loading
+ onboardingRepository.getGroupList(
+ 0,
+ university,
+ search,
+ )
+ .onSuccess { groupList ->
+ if (groupList == null) {
+ _departmentState.value = UiState.Empty
+ return@launch
+ }
+ _departmentState.value = when {
+ groupList.groupList.isEmpty() -> UiState.Empty
+ else -> UiState.Success(groupList)
+ }
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("GET GROUP LIST FAILURE : $t")
+ _departmentState.value = UiState.Failure(t.code().toString())
+ }
+ Timber.e("GET GROUP LIST ERROR : $t")
+ }
+ }
+ }
+
+ private fun getHighSchoolGroupId(group: String) {
+ viewModelScope.launch {
+ onboardingRepository.getGroupHighSchool(
+ highSchool,
+ group,
+ )
+ .onSuccess {
+ if (it == null) {
+ _highSchoolGroupState.value = UiState.Empty
+ return@onSuccess
+ }
+ _highSchoolGroupState.value = UiState.Success(it)
+ _groupId.value = it.groupId
+ }
+ .onFailure {
+ _highSchoolGroupState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+ fun addListWithKakaoIdList() {
+ if (isFriendPagingFinish) return
+ currentFriendOffset += 100
+ currentFriendPage += 1
+ TalkApiClient.instance.friends(
+ offset = currentFriendOffset,
+ limit = 100,
+ ) { friends, error ->
+ if (error != null) {
+ Timber.e(error, "카카오톡 친구목록 가져오기 실패")
+ } else if (friends != null) {
+ totalFriendPage = ceil((friends.totalCount * 0.01)).toInt() - 1
+ if (totalFriendPage == currentFriendPage) isFriendPagingFinish = true
+ val friendIdList: List =
+ friends.elements?.map { friend -> friend.id.toString() } ?: listOf()
+ getKaKaoFriendList(friendIdList, groupId)
+ } else {
+ Timber.d("연동 가능한 카카오톡 친구 없음")
+ }
+ }
+ }
+ private fun getKaKaoFriendList(friendKakaoId: List, groupId: Long) {
+ if (isFirstFriendsListPage) {
+ _friendListState.value = UiState.Loading
+ isFirstFriendsListPage = false
+ }
+ viewModelScope.launch {
+ onboardingRepository.postToGetFriendList(
+ RequestAddFriendModel(friendKakaoId, groupId),
+ 0,
+ )
+ .onSuccess { friendList ->
+ friendList ?: return@launch
+ totalFriendList.addAll(friendList.friendList)
+ _friendListState.value = UiState.Success(totalFriendList)
+ }
+ .onFailure {
+ _friendListState.value = UiState.Failure(it.message.toString())
+ }
+ }
+ }
+
+ fun getValidYelloId(unknownId: String) {
+ viewModelScope.launch {
+ onboardingRepository.getValidYelloId(
+ unknownId,
+ )
+ .onSuccess { isValid ->
+ Timber.d("GET VALID YELLO ID SUCCESS : $isValid")
+ if (isValid == null) {
+ _getValidYelloIdState.value = UiState.Empty
+ return@launch
+ }
+ _getValidYelloIdState.value = UiState.Success(isValid)
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("GET VALID YELLO ID FAILURE : $t")
+ _getValidYelloIdState.value = UiState.Failure(t.code().toString())
+ return@launch
+ }
+ Timber.e("GET VALID YELLO ID ERROR : $t")
+ }
+ }
+ }
+
+ fun postSignup() {
+ viewModelScope.launch {
+ val deviceToken = authRepository.getDeviceToken()
+ onboardingRepository.postSignup(
+ SignupInfo(
+ kakaoId = kakaoId,
+ email = email,
+ profileImg = profileImg,
+ groupId = groupId,
+ studentId = studentId,
+ name = nameText.value.toString(),
+ yelloId = id,
+ gender = gender,
+ friendList = selectedFriendIdList,
+ recommendId = codeText.value,
+ deviceToken = deviceToken,
+ ),
+ )
+ .onSuccess { userInfo ->
+ Timber.d("POST SIGN UP SUCCESS : $userInfo")
+ if (userInfo == null) {
+ _postSignupState.value = UiState.Empty
+ return@launch
+ }
+ authRepository.setAutoLogin(userInfo.accessToken, userInfo.refreshToken)
+ authRepository.setYelloId(userInfo.yelloId)
+ _postSignupState.value = UiState.Success(userInfo)
+ }
+ .onFailure { t ->
+ if (t is HttpException) {
+ Timber.e("POST SIGN UP FAILURE : $t")
+ _postSignupState.value = UiState.Failure(t.code().toString())
+ return@launch
+ } else {
+ t.printStackTrace()
+ }
+ Timber.e("POST SIGN UP ERROR : $t")
+ }
+ }
+ AmplitudeUtils.updateUserProperties("user_sex", gender)
+ AmplitudeUtils.updateUserProperties("user_name", nameText.value.toString())
+ }
+
+ companion object {
+ private const val REGEX_ID_PATTERN = "^([A-Za-z0-9_.]*)\$"
+ private const val REGEX_NAME_PATTERN = "^([가-힣]*)\$"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendAdapter.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendAdapter.kt
new file mode 100644
index 000000000..1e49d3608
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendAdapter.kt
@@ -0,0 +1,30 @@
+package com.el.yello.presentation.onboarding.fragment.addfriend
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.ListAdapter
+import com.el.yello.databinding.ItemAddFriendBinding
+import com.example.domain.entity.onboarding.AddFriendListModel.FriendModel
+import com.example.ui.view.ItemDiffCallback
+
+class AddFriendAdapter(private val itemClick: (FriendModel, Int) -> (Unit)) :
+ ListAdapter(diffUtil) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddFriendViewHolder {
+ val inflater by lazy { LayoutInflater.from(parent.context) }
+ val binding: ItemAddFriendBinding =
+ ItemAddFriendBinding.inflate(inflater, parent, false)
+ return AddFriendViewHolder(binding, itemClick)
+ }
+
+ override fun onBindViewHolder(holder: AddFriendViewHolder, position: Int) {
+ holder.onBind(getItem(position), position)
+ }
+
+ companion object {
+ private val diffUtil = ItemDiffCallback(
+ onItemsTheSame = { old, new -> old.name == new.name },
+ onContentsTheSame = { old, new -> old == new },
+ )
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendFragment.kt
new file mode 100644
index 000000000..0263d9db6
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendFragment.kt
@@ -0,0 +1,153 @@
+package com.el.yello.presentation.onboarding.fragment.addfriend
+
+import android.os.Bundle
+import android.view.View
+import androidx.core.view.isVisible
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.flowWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import com.el.yello.R
+import com.el.yello.databinding.FragmentAddFriendBinding
+import com.el.yello.presentation.onboarding.activity.OnBoardingActivity
+import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.example.domain.entity.onboarding.AddFriendListModel.FriendModel
+import com.example.ui.base.BindingFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.json.JSONObject
+
+@AndroidEntryPoint
+class AddFriendFragment : BindingFragment(R.layout.fragment_add_friend) {
+
+ private val viewModel by activityViewModels()
+
+ private var _adapter: AddFriendAdapter? = null
+ private val adapter
+ get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) }
+
+ private lateinit var friendsList: List
+
+ private var selectedItemIdList = mutableListOf()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.vm = viewModel
+ initFriendAdapter()
+ setConfirmBtnClickListener()
+ setKakaoRecommendList()
+ observeAddListState()
+ (activity as? OnBoardingActivity)?.showBackBtn()
+ }
+
+ private fun initFriendAdapter() {
+ _adapter = AddFriendAdapter { friend, position ->
+ friend.isSelected = !friend.isSelected
+ if (friend.isSelected && friend.id !in selectedItemIdList) {
+ selectedItemIdList.add(friend.id)
+ viewModel.selectedFriendCount.value = viewModel.selectedFriendCount.value?.plus(1)
+ } else {
+ selectedItemIdList.remove(friend.id)
+ viewModel.selectedFriendCount.value = viewModel.selectedFriendCount.value?.minus(1)
+ }
+ adapter.notifyItemChanged(position)
+ }
+ binding.rvFriendList.adapter = adapter
+ }
+
+ private fun setConfirmBtnClickListener() {
+ binding.btnAddFriendNext.setOnSingleClickListener {
+ AmplitudeUtils.trackEventWithProperties(
+ EVENT_CLICK_ONBOARDING_NEXT,
+ JSONObject().put(NAME_ONBOARD_VIEW, VALUE_FRIENDS),
+ )
+ (activity as? OnBoardingActivity)?.progressBarPlus()
+ viewModel.selectedFriendIdList = selectedItemIdList
+ findNavController().navigate(R.id.action_addFriendFragment_to_codeFragment)
+ }
+ }
+
+ private fun setKakaoRecommendList() {
+ setInfinityScroll()
+ viewModel.initFriendPagingVariable()
+ viewModel.addListWithKakaoIdList()
+ }
+
+ private fun setInfinityScroll() {
+ binding.rvFriendList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+ if (dy > 0) {
+ recyclerView.layoutManager?.let { layoutManager ->
+ if (!binding.rvFriendList.canScrollVertically(1) && layoutManager is LinearLayoutManager && layoutManager.findLastVisibleItemPosition() == adapter.itemCount - 1) {
+ viewModel.addListWithKakaoIdList()
+ }
+ }
+ }
+ }
+ })
+ }
+
+ private fun observeAddListState() {
+ viewModel.friendListState.flowWithLifecycle(viewLifecycleOwner.lifecycle)
+ .onEach { state ->
+ when (state) {
+ is UiState.Success -> {
+ stopShimmerView()
+ friendsList = state.data
+ adapter.submitList(friendsList)
+ selectedItemIdList.addAll(friendsList.map { friend -> friend.id })
+ viewModel.selectedFriendCount.value = friendsList.size
+ }
+
+ is UiState.Failure -> {
+ stopShimmerView()
+ toast(getString(R.string.onboarding_add_friend_error))
+ }
+
+ is UiState.Loading -> startShimmerView()
+
+ is UiState.Empty -> return@onEach
+ }
+ }.launchIn(viewLifecycleOwner.lifecycleScope)
+ }
+
+ private fun startShimmerView() {
+ with(binding) {
+ shimmerFriendList.startShimmer()
+ shimmerFriendList.isVisible = true
+ rvFriendList.isVisible = false
+ shimmerTotalFriend.isVisible = true
+ btnAddFriendNext.isClickable = false
+ }
+ }
+
+ private fun stopShimmerView() {
+ with(binding) {
+ shimmerFriendList.stopShimmer()
+ shimmerFriendList.isVisible = false
+ rvFriendList.isVisible = true
+ shimmerTotalFriend.isVisible = false
+ btnAddFriendNext.isClickable = true
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _adapter = null
+ }
+
+ companion object {
+ private const val EVENT_CLICK_ONBOARDING_NEXT = "click_onboarding_next"
+ private const val NAME_ONBOARD_VIEW = "onboard_view"
+ private const val VALUE_FRIENDS = "friends"
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendViewHolder.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendViewHolder.kt
new file mode 100644
index 000000000..6755dd327
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/addfriend/AddFriendViewHolder.kt
@@ -0,0 +1,45 @@
+package com.el.yello.presentation.onboarding.fragment.addfriend
+
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import coil.transform.CircleCropTransformation
+import com.el.yello.R
+import com.el.yello.databinding.ItemAddFriendBinding
+import com.example.domain.entity.onboarding.AddFriendListModel
+import com.example.ui.view.setOnSingleClickListener
+
+class AddFriendViewHolder(
+ private val binding: ItemAddFriendBinding,
+ private val itemClick: (AddFriendListModel.FriendModel, Int) -> Unit,
+) : RecyclerView.ViewHolder(binding.root) {
+
+ fun onBind(friendModel: AddFriendListModel.FriendModel, position: Int) {
+ with(binding) {
+ ivFriendProfile.load(friendModel.profileImage) {
+ transformations(CircleCropTransformation())
+ }
+ tvFriendName.text = friendModel.name
+ tvFriendDepartment.text = friendModel.groupName
+ btnFriendCheck.isSelected = friendModel.isSelected
+
+ tvFriendName.setTextColor(
+ ContextCompat.getColor(
+ itemView.context,
+ if (friendModel.isSelected) R.color.white else R.color.grayscales_onbarding_light,
+ ),
+ )
+
+ tvFriendDepartment.setTextColor(
+ ContextCompat.getColor(
+ itemView.context,
+ if (friendModel.isSelected) R.color.grayscales_500 else R.color.grayscales_onbarding_dark,
+ ),
+ )
+
+ btnFriendCheck.setOnSingleClickListener {
+ itemClick(friendModel, position)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/checkName/CheckNameDialog.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/checkName/CheckNameDialog.kt
new file mode 100644
index 000000000..09b0a1aea
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/checkName/CheckNameDialog.kt
@@ -0,0 +1,76 @@
+package com.el.yello.presentation.onboarding.fragment.checkName
+
+import android.content.Intent
+import android.content.res.Resources
+import android.os.Bundle
+import android.view.View
+import android.view.WindowManager
+import com.el.yello.R
+import com.el.yello.databinding.FragmentDialogCheckNameBinding
+import com.el.yello.presentation.auth.SignInActivity.Companion.EXTRA_NAME
+import com.el.yello.presentation.onboarding.activity.EditNameActivity
+import com.el.yello.presentation.onboarding.activity.OnBoardingActivity
+import com.example.ui.base.BindingDialogFragment
+import com.example.ui.fragment.toast
+import com.example.ui.view.setOnSingleClickListener
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class CheckNameDialog :
+ BindingDialogFragment(R.layout.fragment_dialog_check_name) {
+
+ override fun onStart() {
+ super.onStart()
+ setDialogBackground()
+ }
+
+ private fun setDialogBackground() {
+ val deviceWidth = Resources.getSystem().displayMetrics.widthPixels
+ val dialogHorizontalMargin = (Resources.getSystem().displayMetrics.density * 16) * 2
+
+ dialog?.window?.apply {
+ setLayout(
+ (deviceWidth - dialogHorizontalMargin * 2).toInt(),
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ )
+ setBackgroundDrawableResource(R.color.transparent)
+ }
+ dialog?.setCanceledOnTouchOutside(false)
+ dialog?.setCancelable(false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initEditBtnListener()
+ }
+
+ private fun initEditBtnListener() {
+ val bundle = arguments
+ if (bundle != null) {
+ with(binding) {
+ tvNameEditDialogTitleTwo.text = bundle.getString(EXTRA_NAME)
+ btnNameEditDialogYes.setOnSingleClickListener {
+ Intent(requireContext(), OnBoardingActivity::class.java).apply {
+ putExtras(bundle)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ dismiss()
+ requireActivity().finish()
+ }
+ btnNameEditDialogNo.setOnSingleClickListener {
+ Intent(requireContext(), EditNameActivity::class.java).apply {
+ putExtras(bundle)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(this)
+ }
+ dismiss()
+ requireActivity().finish()
+ }
+ }
+ } else {
+ toast(getString(R.string.sign_in_error_connection))
+ }
+ }
+}
diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/code/CodeFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/code/CodeFragment.kt
new file mode 100644
index 000000000..513a7de76
--- /dev/null
+++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/code/CodeFragment.kt
@@ -0,0 +1,152 @@
+package com.el.yello.presentation.onboarding.fragment.code
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.activityViewModels
+import com.el.yello.R
+import com.el.yello.databinding.FragmentCodeBinding
+import com.el.yello.presentation.onboarding.activity.GetAlarmActivity
+import com.el.yello.presentation.onboarding.activity.OnBoardingActivity
+import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel
+import com.el.yello.util.amplitude.AmplitudeUtils
+import com.el.yello.util.context.yelloSnackbar
+import com.example.ui.base.BindingFragment
+import com.example.ui.fragment.colorOf
+import com.example.ui.view.UiState
+import com.example.ui.view.setOnSingleClickListener
+import org.json.JSONObject
+
+class CodeFragment : BindingFragment(R.layout.fragment_code) {
+ private val viewModel by activityViewModels