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() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.vm = viewModel + observePostSignupState() + observeGetValidYelloIdState() + setCodeBtnCLickListener() + setDeleteCodeBtnClickListener() + } + + override fun onResume() { + super.onResume() + (activity as? OnBoardingActivity)?.hideBackBtn() + } + + private fun setCodeBtnCLickListener() { + binding.btnCodeSkip.setOnClickListener { + viewModel.postSignup() + amplitudeCodeSkipInfo() + } + binding.btnCodeNext.setOnSingleClickListener { + viewModel.getValidYelloId(viewModel.codeText.value.toString()) + amplitudeCodeNextInfo() + } + } + + private fun observeGetValidYelloIdState() { + viewModel.getValidYelloIdState.observe(viewLifecycleOwner) { state -> + when (state) { + is UiState.Success -> { + if (!state.data) { + initIdEditTextViewError() + return@observe + } + viewModel.postSignup() + } + + is UiState.Failure -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + + is UiState.Loading -> {} + + is UiState.Empty -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + } + } + } + + private fun observePostSignupState() { + viewModel.postSignupState.observe(viewLifecycleOwner) { state -> + when (state) { + is UiState.Success -> { + AmplitudeUtils.setUserDataProperties(PROPERTY_USER_SIGHUP_DATE) + val intent = Intent(activity, GetAlarmActivity::class.java) + startActivity(intent) + (activity as? OnBoardingActivity)?.endTutorialActivity() + } + is UiState.Failure -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + is UiState.Loading -> {} + is UiState.Empty -> {} + } + } + } + + private fun setDeleteCodeBtnClickListener() { + binding.ivCodeDelete.setOnClickListener { + binding.etCode.text.clear() + } + } + + private fun initIdEditTextViewError() { + with(binding) { + etCode.setBackgroundResource(R.drawable.shape_fill_red20_line_semantic_status_red500_rect_8) + ivCodeDelete.setBackgroundResource(R.drawable.ic_onboarding_delete_red) + tvCodeWarning.text = getString(R.string.onboarding_code_duplicate_msg) + tvCodeWarning.setTextColor(colorOf(R.color.semantic_red_500)) + } + } + + private fun amplitudeCodeSkipInfo() { + with(AmplitudeUtils) { + trackEventWithProperties(EVENT_COMPLETE_ONBOARDING_FINISH) + trackEventWithProperties( + EVENT_CLICK_ONBOARDING_RECOMMEND, + JSONObject().put(NAME_REC_EXIST, VALUE_PASS), + ) + updateUserProperties(PROPERTY_USER_RECOMMEND, VALUE_NO) + updateUserProperties( + PROPERTY_USER_NAME, + viewModel.nameText.value.toString(), + ) + updateUserProperties(PROPERTY_USER_SEX, viewModel.gender) + } + } + + private fun amplitudeCodeNextInfo() { + with(AmplitudeUtils) { + trackEventWithProperties(EVENT_COMPLETE_ONBOARDING_FINISH) + trackEventWithProperties( + EVENT_CLICK_ONBOARDING_RECOMMEND, + JSONObject().put(NAME_REC_EXIST, VALUE_NEXT), + ) + updateUserProperties(PROPERTY_USER_RECOMMEND, VALUE_YES) + updateUserProperties( + PROPERTY_USER_NAME, + viewModel.nameText.value.toString(), + ) + updateUserProperties(PROPERTY_USER_SEX, viewModel.gender) + } + } + + companion object { + private const val PROPERTY_USER_SIGHUP_DATE = "user_signup_date" + private const val EVENT_COMPLETE_ONBOARDING_FINISH = "complete_onboarding_finish" + private const val EVENT_CLICK_ONBOARDING_RECOMMEND = "click_onboarding_recommend" + private const val NAME_REC_EXIST = "rec_exist" + private const val VALUE_PASS = "pass" + private const val VALUE_NEXT = "next" + private const val PROPERTY_USER_RECOMMEND = "user_recommend" + private const val VALUE_NO = "no" + private const val VALUE_YES = "yes" + private const val PROPERTY_USER_NAME = "user_name" + private const val PROPERTY_USER_SEX = "user_sex" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/HighSchoolInfoFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/HighSchoolInfoFragment.kt new file mode 100644 index 000000000..50f778c24 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/HighSchoolInfoFragment.kt @@ -0,0 +1,153 @@ +package com.el.yello.presentation.onboarding.fragment.highschoolinfo + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.el.yello.R +import com.el.yello.databinding.FragmentHighschoolBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingActivity +import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel +import com.el.yello.presentation.onboarding.fragment.highschoolinfo.group.GroupDialogFragment +import com.el.yello.presentation.onboarding.fragment.highschoolinfo.school.SearchDialogHighSchoolFragment +import com.el.yello.util.amplitude.AmplitudeUtils +import com.el.yello.util.context.yelloSnackbar +import com.example.domain.enum.Grade +import com.example.ui.base.BindingFragment +import com.example.ui.context.colorOf +import com.example.ui.view.setOnSingleClickListener +import org.json.JSONObject + +class HighSchoolInfoFragment : + BindingFragment(R.layout.fragment_highschool) { + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + with(binding) { + firstGrade = Grade.A.toInt() + secondGrade = Grade.B.toInt() + thirdGrade = Grade.C.toInt() + } + setupHighSchool() + setupGrade() + setupHighSchoolGroup() + initSearchInfoBtnClickListener() + setConfirmBtnClickListener() + } + + override fun onResume() { + super.onResume() + (activity as? OnBoardingActivity)?.showBackBtn() + } + + private fun initSearchInfoBtnClickListener() { + binding.tvHighschoolSearch.setOnSingleClickListener { + SearchDialogHighSchoolFragment().show(parentFragmentManager, this.tag) + } + binding.tvGroupSearch.setOnSingleClickListener { + if (binding.tvHighschoolSearch.text.isNotBlank()) { + GroupDialogFragment().show(parentFragmentManager, this.tag) + } else { + yelloSnackbar(binding.root.rootView, "학교와 학년을 선택해야 반을 선택할 수 있어요!") + } + } + } + + private fun setupHighSchool() { + viewModel.highSchoolText.observe(viewLifecycleOwner) { school -> + binding.tvHighschoolSearch.text = school + } + } + + private fun setupGrade() { + viewModel.studentIdText.observe(viewLifecycleOwner) { grade -> + when (grade) { + Grade.A.toInt() -> { + changeFirstGradeBtn() + } + + Grade.B.toInt() -> { + changeSecondGradeBtn() + } + + Grade.C.toInt() -> { + changeThirdGradeBtn() + } + } + } + } + + private fun setupHighSchoolGroup() { + viewModel.highSchoolGroupText.observe(viewLifecycleOwner) { group -> + binding.tvGroupSearch.text = getString(R.string.onboarding_group, group) + } + } + + private fun setConfirmBtnClickListener() { + binding.btnHighschoolinfoNextBtn.setOnSingleClickListener { + findNavController().navigate(R.id.action_highschoolInfoFragment_to_yelIoIdFragment) + amplitudeHighSchoolInfo() + val activity = requireActivity() as OnBoardingActivity + activity.progressBarPlus() + } + } + + private fun changeFirstGradeBtn() { + with(binding) { + tvGradeFirst.setBackgroundResource(R.drawable.shape_grayscales900_fill_yello_main600_line_8_leftrect) + tvGradeFirst.setTextColor(binding.root.context.colorOf(R.color.yello_main_600)) + tvGradeSecond.setBackgroundResource(R.drawable.shape_grayscales900_fill_grayscales700_line_8_square) + tvGradeSecond.setTextColor(binding.root.context.colorOf(R.color.grayscales_700)) + tvGradeThird.setBackgroundResource(R.drawable.shape_grayscales900_fill_grayscales700_line_8_rightrect) + tvGradeThird.setTextColor(binding.root.context.colorOf(R.color.grayscales_700)) + } + } + + private fun changeSecondGradeBtn() { + with(binding) { + tvGradeFirst.setBackgroundResource(R.drawable.shape_grayscales900_fill_grayscales700_line_8_leftrect) + tvGradeFirst.setTextColor(binding.root.context.colorOf(R.color.grayscales_700)) + tvGradeSecond.setBackgroundResource(R.drawable.shape_grayscales900_fill_yello_main600_line_8_square) + tvGradeSecond.setTextColor(binding.root.context.colorOf(R.color.yello_main_600)) + tvGradeThird.setBackgroundResource(R.drawable.shape_grayscales900_fill_grayscales700_line_8_rightrect) + tvGradeThird.setTextColor(binding.root.context.colorOf(R.color.grayscales_700)) + } + } + + private fun changeThirdGradeBtn() { + with(binding) { + tvGradeFirst.setBackgroundResource(R.drawable.shape_grayscales900_fill_grayscales700_line_8_leftrect) + tvGradeFirst.setTextColor(binding.root.context.colorOf(R.color.grayscales_700)) + tvGradeSecond.setBackgroundResource(R.drawable.shape_grayscales900_fill_grayscales700_line_8_square) + tvGradeSecond.setTextColor(binding.root.context.colorOf(R.color.grayscales_700)) + tvGradeThird.setBackgroundResource(R.drawable.shape_grayscales900_fill_yello_main600_line_8_rightrect) + tvGradeThird.setTextColor(binding.root.context.colorOf(R.color.yello_main_600)) + } + } + + private fun amplitudeHighSchoolInfo() { + with(AmplitudeUtils) { + trackEventWithProperties( + EVENT_CLICK_ONBOARDING_NEXT, + JSONObject().put(NAME_ONBOARD_VIEW, VALUE_SCHOOL), + ) + updateUserProperties(PROPERTY_USER_SCHOOL, viewModel.highSchool) + updateUserProperties( + PROPERTY_USER_DEPARTMENT, + viewModel.highSchoolGroupText.value.toString(), + ) + updateUserIntProperties(PROPERTY_USER_GRADE, viewModel.studentId) + } + } + + companion object { + private const val EVENT_CLICK_ONBOARDING_NEXT = "click_onboarding_next" + private const val NAME_ONBOARD_VIEW = "onboard_view" + private const val VALUE_SCHOOL = "school" + private const val PROPERTY_USER_SCHOOL = "user_school" + private const val PROPERTY_USER_DEPARTMENT = "user_department" + private const val PROPERTY_USER_GRADE = "user_grade" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/group/GroupDialogAdapter.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/group/GroupDialogAdapter.kt new file mode 100644 index 000000000..cd8141c08 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/group/GroupDialogAdapter.kt @@ -0,0 +1,51 @@ +package com.el.yello.presentation.onboarding.fragment.highschoolinfo.group + +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.ItemGroupListBinding +import com.example.ui.view.ItemDiffCallback +import com.example.ui.view.setOnSingleClickListener + +class GroupDialogAdapter( + private val storeHighSchoolGroup: (String) -> Unit, +) : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupViewHolder { + return GroupViewHolder( + ItemGroupListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + storeHighSchoolGroup, + ) + } + + override fun onBindViewHolder(holder: GroupViewHolder, position: Int) { + holder.setHighSchoolGroup(getItem(position)) + } + + class GroupViewHolder( + private val binding: ItemGroupListBinding, + private val storeHighSchoolGroup: (String) -> Unit, + ) : + RecyclerView.ViewHolder(binding.root) { + fun setHighSchoolGroup(group: String) { + binding.group = group + binding.root.setOnSingleClickListener { + storeHighSchoolGroup(group) + binding.tvItemGroup.setBackgroundResource(R.drawable.shape_grayscales_800_fill_100_rect) + } + } + } + + companion object { + private val diffUtil = ItemDiffCallback( + onItemsTheSame = { old, new -> old == new }, + onContentsTheSame = { old, new -> old == new }, + ) + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/group/GroupDialogFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/group/GroupDialogFragment.kt new file mode 100644 index 000000000..bf47b5a45 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/group/GroupDialogFragment.kt @@ -0,0 +1,41 @@ +package com.el.yello.presentation.onboarding.fragment.highschoolinfo.group + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import com.el.yello.R +import com.el.yello.databinding.FragmentDialogGroupBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel +import com.example.ui.base.BindingBottomSheetDialog + +class GroupDialogFragment : + BindingBottomSheetDialog(R.layout.fragment_dialog_group) { + + private lateinit var groupList: List + + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + initGroupAdapter() + } + + private fun initGroupAdapter() { + viewModel.addHighSchoolGroup() + groupList = viewModel.highSchoolGroupList.value ?: emptyList() + val adapter = GroupDialogAdapter(storeHighSchoolGroup = ::storeHighSchoolGroup) + binding.rvGroup.adapter = adapter + adapter.submitList(groupList) + } + + private fun storeHighSchoolGroup(group: String) { + viewModel.setGroupHighSchoolInfo(group) + dismiss() + } + + companion object { + @JvmStatic + fun newInstance() = GroupDialogFragment() + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/school/HighSchoolAdapter.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/school/HighSchoolAdapter.kt new file mode 100644 index 000000000..ace36e53a --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/school/HighSchoolAdapter.kt @@ -0,0 +1,51 @@ +package com.el.yello.presentation.onboarding.fragment.highschoolinfo.school + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.el.yello.databinding.ItemHighschoolListBinding +import com.example.ui.view.ItemDiffCallback +import com.example.ui.view.setOnSingleClickListener + +class HighSchoolAdapter( + private val storeHighSchool: (String) -> Unit, +) : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HighSchoolViewHolder { + return HighSchoolViewHolder( + ItemHighschoolListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + storeHighSchool, + ) + } + + override fun onBindViewHolder(holder: HighSchoolViewHolder, position: Int) { + holder.setHighSchool(getItem(position)) + } + + class HighSchoolViewHolder( + private val binding: ItemHighschoolListBinding, + private val storeHighSchool: (String) -> Unit, + ) : + RecyclerView.ViewHolder(binding.root) { + fun setHighSchool(highSchool: String) { + binding.data = highSchool + binding.root.setOnSingleClickListener { + storeHighSchool(highSchool) + } + binding.tvHighschoolName.setOnSingleClickListener { + storeHighSchool(highSchool) + } + } + } + + companion object { + private val diffUtil = ItemDiffCallback( + onItemsTheSame = { old, new -> old == new }, + onContentsTheSame = { old, new -> old == new }, + ) + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/school/SearchDialogHighSchoolFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/school/SearchDialogHighSchoolFragment.kt new file mode 100644 index 000000000..ea354277a --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/highschoolinfo/school/SearchDialogHighSchoolFragment.kt @@ -0,0 +1,170 @@ +package com.el.yello.presentation.onboarding.fragment.highschoolinfo.school + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import androidx.core.widget.doAfterTextChanged +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.FragmentDialogHighschoolBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel +import com.el.yello.util.context.yelloSnackbar +import com.example.ui.base.BindingBottomSheetDialog +import com.example.ui.context.hideKeyboard +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 SearchDialogHighSchoolFragment : + BindingBottomSheetDialog(R.layout.fragment_dialog_highschool) { + private val viewModel by activityViewModels() + private var adapter: HighSchoolAdapter? = null + private var inputText: String = "" + private val debounceTime = 500L + private var searchJob: Job? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + initHighSchoolDialogView() + setupHighSchoolData() + setClickToSchoolForm() + setListWithInfinityScroll() + recyclerviewScroll() + } + + private fun initHighSchoolDialogView() { + setHideKeyboard() + binding.etHighschoolSearch.doAfterTextChanged { input -> + searchJob?.cancel() + searchJob = viewModel.viewModelScope.launch { + delay(debounceTime) + input?.toString()?.let { viewModel.getHighSchoolList(it) } + } + } + adapter = HighSchoolAdapter(storeHighSchool = ::storeHighSchool) + binding.rvHighschoolList.adapter = adapter + binding.btnHighschoolBackDialog.setOnSingleClickListener { + dismiss() + } + } + + 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 { it -> + val behaviour = BottomSheetBehavior.from(it) + setupFullHeight(it) + behaviour.state = BottomSheetBehavior.STATE_EXPANDED + } + } + return dialog + } + + private fun setupHighSchoolData() { + viewModel.highSchoolState.flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { state -> + when (state) { + is UiState.Success -> { + adapter?.submitList(state.data.groupNameList) + } + + is UiState.Failure -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + + is UiState.Loading -> return@onEach + is UiState.Empty -> return@onEach + } + }.launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun storeHighSchool(school: String) { + viewModel.setHighSchool(school) + viewModel.clearHighSchoolData() + dismiss() + } + + private fun setClickToSchoolForm() { + binding.tvHighschoolAdd.setOnClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(HIGH_SCHOOL_FORM_URL)).apply { + startActivity(this) + } + } + } + + private fun setupFullHeight(bottomSheet: View) { + val layoutParams = bottomSheet.layoutParams + layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT + bottomSheet.layoutParams = layoutParams + } + + private fun setListWithInfinityScroll() { + binding.rvHighschoolList.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.rvHighschoolList.canScrollVertically(1) && + (layoutManager is LinearLayoutManager) && + (layoutManager.findLastVisibleItemPosition() == (adapter!!.itemCount - 1)) + ) { + viewModel.getUniversityList(inputText) + } + } + } + } + }) + } + + private fun setHideKeyboard() { + binding.layoutHighschoolDialog.setOnSingleClickListener { + requireContext().hideKeyboard( + requireView(), + ) + } + } + + private fun recyclerviewScroll() { + binding.rvHighschoolList.addOnItemTouchListener(object : RecyclerView.OnItemTouchListener { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.action) { + MotionEvent.ACTION_MOVE -> { + binding.layoutHighschoolDialog.requestDisallowInterceptTouchEvent(true) + } + } + return false + } + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {} + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} + }) + } + override fun onDestroyView() { + super.onDestroyView() + adapter = null + } + + companion object { + @JvmStatic + fun newInstance() = SearchDialogHighSchoolFragment() + private const val HIGH_SCHOOL_FORM_URL = "https://forms.gle/sMyn6uq7oHDovSdi8" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/studenttype/SelectStudentFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/studenttype/SelectStudentFragment.kt new file mode 100644 index 000000000..90bed8f54 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/studenttype/SelectStudentFragment.kt @@ -0,0 +1,97 @@ +package com.el.yello.presentation.onboarding.fragment.studenttype + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.el.yello.R +import com.el.yello.databinding.FragmentSelectStudentTypeBinding +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.enum.StudentType +import com.example.ui.base.BindingFragment +import com.example.ui.context.colorOf +import com.example.ui.fragment.colorOf +import com.example.ui.view.setOnSingleClickListener +import org.json.JSONObject + +class SelectStudentFragment : + BindingFragment(R.layout.fragment_select_student_type) { + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + binding.highschool = StudentType.H.toString() + binding.university = StudentType.U.toString() + setupStudentType() + } + + override fun onResume() { + super.onResume() + (activity as? OnBoardingActivity)?.hideBackBtn() + } + private fun setupStudentType() { + viewModel.studentType.observe(viewLifecycleOwner) { studentType -> + when (studentType) { + StudentType.H.toString() -> { + changeHighSchoolBtn() + binding.btnSelectTypeNext.setOnSingleClickListener { + findNavController().navigate(R.id.action_selectStudentFragment_to_highschoolInfoFragment) + amplitudeSelectStudent() + AmplitudeUtils.updateUserProperties(EVENT_STUDENT_TYPE, VALUE_HIGH_SCHOOL) + val activity = requireActivity() as OnBoardingActivity + activity.progressBarPlus() + } + } + + StudentType.U.toString() -> { + changeUniversityBtn() + binding.btnSelectTypeNext.setOnSingleClickListener { + findNavController().navigate(R.id.action_selectStudentFragment_to_universityInfoFragment) + amplitudeSelectStudent() + AmplitudeUtils.updateUserProperties(EVENT_STUDENT_TYPE, VALUE_UNIVERSITY) + val activity = requireActivity() as OnBoardingActivity + activity.progressBarPlus() + } + } + } + } + } + + private fun changeHighSchoolBtn() { + with(binding) { + btnSchoolHighschool.setBackgroundResource(R.drawable.shape_black_fill_yello_main_500_line_8_rect) + btnSchoolUniversity.setBackgroundResource(R.drawable.shape_black_fill_grayscales700_line_8_rect) + ivStudentHighschool.setImageResource(R.drawable.ic_student_highschool_face_select) + ivStudentUniversity.setImageResource(R.drawable.ic_student_university_face_unselected) + tvStudentHighschool.setTextColor(colorOf(R.color.yello_main_500)) + tvStudentUniversity.setTextColor(colorOf(R.color.grayscales_700)) + } + } + + private fun changeUniversityBtn() { + with(binding) { + btnSchoolUniversity.setBackgroundResource(R.drawable.shape_black_fill_yello_main_500_line_8_rect) + btnSchoolHighschool.setBackgroundResource(R.drawable.shape_black_fill_grayscales700_line_8_rect) + ivStudentUniversity.setImageResource(R.drawable.ic_student_university_face_select) + ivStudentHighschool.setImageResource(R.drawable.ic_student_highschool_face_unselected) + tvStudentUniversity.setTextColor(colorOf(R.color.yello_main_500)) + tvStudentHighschool.setTextColor(colorOf(R.color.grayscales_700)) + } + } + + private fun amplitudeSelectStudent() { + AmplitudeUtils.trackEventWithProperties( + "click_onboarding_next", + JSONObject().put("onboard_view", "student_type"), + ) + } + + companion object { + private const val EVENT_STUDENT_TYPE = "user_student_type" + private const val VALUE_HIGH_SCHOOL = "highschool" + private const val VALUE_UNIVERSITY = "university" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/UniversityInfoFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/UniversityInfoFragment.kt new file mode 100644 index 000000000..55fd7a464 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/UniversityInfoFragment.kt @@ -0,0 +1,115 @@ +package com.el.yello.presentation.onboarding.fragment.universityinfo + +import android.os.Bundle +import android.view.View +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.el.yello.R +import com.el.yello.databinding.FragmentUniversityBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingActivity +import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel +import com.el.yello.presentation.onboarding.fragment.universityinfo.department.SearchDialogDepartmentFragment +import com.el.yello.presentation.onboarding.fragment.universityinfo.studentid.StudentIdDialogFragment +import com.el.yello.presentation.onboarding.fragment.universityinfo.university.SearchDialogUniversityFragment +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 org.json.JSONObject + +class UniversityInfoFragment : + BindingFragment(R.layout.fragment_university) { + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.vm = viewModel + initSearchInfoBtnClickListener() + setConfirmBtnClickListener() + setupUniversity() + setupDepartment() + setupStudentId() + } + override fun onResume() { + super.onResume() + (activity as? OnBoardingActivity)?.showBackBtn() + } + + private fun initSearchInfoBtnClickListener() { + binding.tvUniversitySearch.setOnSingleClickListener { + SearchDialogUniversityFragment().show(parentFragmentManager, this.tag) + } + binding.tvDepartmentSearch.setOnSingleClickListener { + if (binding.tvUniversitySearch.text.isNotBlank()) { + SearchDialogDepartmentFragment().show(parentFragmentManager, this.tag) + } else { + yelloSnackbar(binding.root.rootView, "학교를 선택해야 학과를 선택할 수 있어요!") + } + } + binding.tvStudentidSearch.setOnSingleClickListener { + StudentIdDialogFragment().show(parentFragmentManager, this.tag) + } + } + + private fun setupUniversity() { + viewModel.universityText.observe(viewLifecycleOwner) { school -> + binding.tvUniversitySearch.text = school + binding.tvDepartmentSearch.text = "" + } + } + + private fun setupDepartment() { + viewModel.departmentText.observe(viewLifecycleOwner) { department -> + binding.tvDepartmentSearch.text = department + } + binding.tvDepartmentSearch.doAfterTextChanged { + it.toString() + } + } + + private fun setupStudentId() { + viewModel.studentIdText.observe(viewLifecycleOwner) { studentId -> + if (studentId in OVERLAP_MIN_ID..OVERLAP_MAX_ID) { + binding.tvStudentidSearch.text = "" + } else { + binding.tvStudentidSearch.text = getString(R.string.onboarding_student_id, studentId) + } + } + } + + private fun setConfirmBtnClickListener() { + binding.btnUniversityInfoNext.setOnSingleClickListener { + amplitudeUniversityInfo() + findNavController().navigate(R.id.action_universityInfoFragment_to_yelIoIdFragment) + val activity = requireActivity() as OnBoardingActivity + activity.progressBarPlus() + } + } + + private fun amplitudeUniversityInfo() { + with(AmplitudeUtils) { + trackEventWithProperties( + EVENT_CLICK_ONBOARDING_NEXT, + JSONObject().put(NAME_ONBOARD_VIEW, VALUE_SCHOOL), + ) + updateUserProperties(PROPERTY_USER_SCHOOL, viewModel.university) + updateUserProperties( + PROPERTY_USER_DEPARTMENT, + viewModel.departmentText.value.toString(), + ) + updateUserIntProperties(PROPERTY_USER_GRADE, viewModel.studentId) + } + } + companion object { + private const val OVERLAP_MIN_ID = 1 + private const val OVERLAP_MAX_ID = 3 + private const val EVENT_CLICK_ONBOARDING_NEXT = "click_onboarding_next" + private const val NAME_ONBOARD_VIEW = "onboard_view" + private const val VALUE_SCHOOL = "school" + private const val PROPERTY_USER_SCHOOL = "user_school" + private const val PROPERTY_USER_DEPARTMENT = "user_department" + private const val PROPERTY_USER_GRADE = "user_grade" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/department/DepartmentAdapter.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/department/DepartmentAdapter.kt new file mode 100644 index 000000000..bade637d7 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/department/DepartmentAdapter.kt @@ -0,0 +1,54 @@ +package com.el.yello.presentation.onboarding.fragment.universityinfo.department + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.el.yello.databinding.ItemDepartmentListBinding +import com.example.domain.entity.onboarding.Group +import com.example.ui.view.ItemDiffCallback +import com.example.ui.view.setOnSingleClickListener + +class DepartmentAdapter( + private val storeDepartment: (String, Long) -> Unit, +) : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DepartmentViewHolder { + return DepartmentViewHolder( + ItemDepartmentListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + storeDepartment, + ) + } + + override fun onBindViewHolder(holder: DepartmentViewHolder, position: Int) { + holder.setDepartment(getItem(position)) + } + + class DepartmentViewHolder( + private val binding: ItemDepartmentListBinding, + private val storeDepartment: (String, Long) -> Unit, + ) : + RecyclerView.ViewHolder(binding.root) { + fun setDepartment(department: Group) { + with(binding) { + data = department.name + root.setOnSingleClickListener { + storeDepartment(department.name, department.groupId) + } + tvDepartmentName.setOnSingleClickListener { + storeDepartment(department.name, department.groupId) + } + } + } + } + + companion object { + private val diffUtil = ItemDiffCallback( + onItemsTheSame = { old, new -> old == new }, + onContentsTheSame = { old, new -> old == new }, + ) + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/department/SearchDialogDepartmentFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/department/SearchDialogDepartmentFragment.kt new file mode 100644 index 000000000..2baad7d0f --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/department/SearchDialogDepartmentFragment.kt @@ -0,0 +1,174 @@ +package com.el.yello.presentation.onboarding.fragment.universityinfo.department + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import androidx.core.widget.doAfterTextChanged +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.FragmentDialogDepartmentBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel +import com.el.yello.util.context.yelloSnackbar +import com.example.ui.base.BindingBottomSheetDialog +import com.example.ui.context.hideKeyboard +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 +import timber.log.Timber + +class SearchDialogDepartmentFragment : + BindingBottomSheetDialog(R.layout.fragment_dialog_department) { + private val viewModel by activityViewModels() + private var adapter: DepartmentAdapter? = null + private var inputText: String = "" + private val debounceTime = 500L + private var searchJob: Job? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + + initDepartmentDialogView() + setupDepartmentData() + recyclerviewScroll() + setClickToDepartmentForm() + setListWithInfinityScroll() + } + + private fun initDepartmentDialogView() { + setHideKeyboard() + binding.etDepartmentSearch.doAfterTextChanged { it -> + searchJob?.cancel() + searchJob = viewModel.viewModelScope.launch { + delay(debounceTime) + it?.toString()?.let { viewModel.getUniversityGroupId(it) } + } + } + adapter = DepartmentAdapter(storeDepartment = ::storeUniversityGroup) + binding.rvDepartmentList.adapter = adapter + binding.btnBackDialog.setOnSingleClickListener { + dismiss() + } + } + + 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 { it -> + val behaviour = BottomSheetBehavior.from(it) + setupFullHeight(it) + behaviour.state = BottomSheetBehavior.STATE_EXPANDED + } + } + return dialog + } + + private fun setupDepartmentData() { + viewModel.departmentState.flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { state -> + Timber.d("GET GROUP LIST OBSERVE : $state") + when (state) { + is UiState.Success -> { + adapter?.submitList(state.data.groupList) + } + + is UiState.Failure -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + + is UiState.Loading -> return@onEach + is UiState.Empty -> return@onEach + } + }.launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun storeUniversityGroup(department: String, groupId: Long) { + viewModel.setGroupUniversityInfo(department, groupId) + viewModel.clearDepartmentData() + dismiss() + } + + private fun setClickToDepartmentForm() { + binding.tvDepartmentAdd.setOnClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(DEPARTMENT_FORM_URL)).apply { + startActivity(this) + } + } + } + + private fun setupFullHeight(bottomSheet: View) { + val layoutParams = bottomSheet.layoutParams + layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT + bottomSheet.layoutParams = layoutParams + } + + private fun setListWithInfinityScroll() { + binding.rvDepartmentList.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.rvDepartmentList.canScrollVertically(1) && + layoutManager is LinearLayoutManager && + layoutManager.findLastVisibleItemPosition() == adapter!!.itemCount - 1 + ) { + viewModel.getUniversityGroupId(inputText) + } + } + } + } + }) + } + + private fun setHideKeyboard() { + binding.layoutDepartmentDialog.setOnSingleClickListener { + requireContext().hideKeyboard( + requireView(), + ) + } + } + + private fun recyclerviewScroll() { + binding.rvDepartmentList.addOnItemTouchListener(object : RecyclerView.OnItemTouchListener { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.action) { + MotionEvent.ACTION_MOVE -> { + binding.layoutDepartmentDialog.requestDisallowInterceptTouchEvent(true) + } + } + return false + } + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {} + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} + }) + } + + override fun onDestroyView() { + adapter = null + super.onDestroyView() + } + + companion object { + @JvmStatic + fun newInstance() = SearchDialogDepartmentFragment() + const val DEPARTMENT_FORM_URL = "https://bit.ly/3pO0ijD" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/studentid/StudentIdDialogAdapter.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/studentid/StudentIdDialogAdapter.kt new file mode 100644 index 000000000..41dd69073 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/studentid/StudentIdDialogAdapter.kt @@ -0,0 +1,50 @@ +package com.el.yello.presentation.onboarding.fragment.universityinfo.studentid + +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.ItemStudentidListBinding +import com.example.ui.view.ItemDiffCallback +import com.example.ui.view.setOnSingleClickListener + +class StudentIdDialogAdapter( + private val storeStudentId: (Int) -> Unit, +) : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StudentIdViewHolder { + return StudentIdViewHolder( + ItemStudentidListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + storeStudentId, + ) + } + + override fun onBindViewHolder(holder: StudentIdViewHolder, position: Int) { + holder.setStudentId(getItem(position)) + } + + class StudentIdViewHolder( + private val binding: ItemStudentidListBinding, + private val storeStudentId: (Int) -> Unit, + ) : + RecyclerView.ViewHolder(binding.root) { + fun setStudentId(id: Int) { + binding.studentId = id + binding.root.setOnSingleClickListener { + storeStudentId(id) + binding.tvItemStudentId.setBackgroundResource(R.drawable.shape_grayscales_800_fill_100_rect) + } + } + } + + companion object { + private val diffUtil = ItemDiffCallback( + onItemsTheSame = { old, new -> old == new }, + onContentsTheSame = { old, new -> old == new }, + ) + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/studentid/StudentIdDialogFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/studentid/StudentIdDialogFragment.kt new file mode 100644 index 000000000..94185d164 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/studentid/StudentIdDialogFragment.kt @@ -0,0 +1,40 @@ +package com.el.yello.presentation.onboarding.fragment.universityinfo.studentid + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import com.el.yello.R +import com.el.yello.databinding.FragmentDialogStudentIdBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel +import com.example.ui.base.BindingBottomSheetDialog + +class StudentIdDialogFragment : + BindingBottomSheetDialog(R.layout.fragment_dialog_student_id) { + + private lateinit var studentIdList: List + + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + initStudentIdViewAdapter() + } + private fun initStudentIdViewAdapter() { + viewModel.addUniversityStudentId() + studentIdList = viewModel.studentIdResult.value ?: emptyList() + val adapter = StudentIdDialogAdapter(storeStudentId = ::storeStudentId) + binding.rvStudentid.adapter = adapter + adapter.submitList(studentIdList) + } + + private fun storeStudentId(studentId: Int) { + viewModel.setStudentId(studentId) + dismiss() + } + + companion object { + @JvmStatic + fun newInstance() = StudentIdDialogFragment() + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/university/SearchDialogUniversityFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/university/SearchDialogUniversityFragment.kt new file mode 100644 index 000000000..793cdf866 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/university/SearchDialogUniversityFragment.kt @@ -0,0 +1,168 @@ +package com.el.yello.presentation.onboarding.fragment.universityinfo.university + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import androidx.core.widget.doAfterTextChanged +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.FragmentDialogUniversityBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingViewModel +import com.el.yello.util.context.yelloSnackbar +import com.example.ui.base.BindingBottomSheetDialog +import com.example.ui.context.hideKeyboard +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 SearchDialogUniversityFragment : + BindingBottomSheetDialog(R.layout.fragment_dialog_university) { + private var adapter: UniversityAdapter? = null + private val viewModel by activityViewModels() + private var inputText: String = "" + private val debounceTime = 500L + private var searchJob: Job? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + initUniversityDialogView() + setupUniversityData() + setClickToSchoolForm() + setListWithInfinityScroll() + recyclerviewScroll() + } + + private fun initUniversityDialogView() { + setHideKeyboard() + binding.etSchoolSearch.doAfterTextChanged { input -> + searchJob?.cancel() + searchJob = viewModel.viewModelScope.launch { + delay(debounceTime) + input?.toString()?.let { viewModel.getUniversityList(it) } + } + } + adapter = UniversityAdapter(storeUniversity = ::storeUniversity) + binding.rvSchoolList.adapter = adapter + binding.btnBackDialog.setOnSingleClickListener { + dismiss() + } + } + + 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 { it -> + val behaviour = BottomSheetBehavior.from(it) + setupFullHeight(it) + behaviour.state = BottomSheetBehavior.STATE_EXPANDED + } + } + return dialog + } + + private fun setupUniversityData() { + viewModel.universityState.flowWithLifecycle(viewLifecycleOwner.lifecycle) + .onEach { state -> + when (state) { + is UiState.Success -> { + adapter?.submitList(state.data.schoolList) + } + is UiState.Failure -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + is UiState.Loading -> return@onEach + is UiState.Empty -> return@onEach + } + }.launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun storeUniversity(school: String) { + viewModel.setUniversity(school) + viewModel.clearUniversityData() + dismiss() + } + + private fun setClickToSchoolForm() { + binding.tvSchoolAdd.setOnClickListener { + Intent(Intent.ACTION_VIEW, Uri.parse(SCHOOL_FORM_URL)).apply { + startActivity(this) + } + } + } + + private fun setupFullHeight(bottomSheet: View) { + val layoutParams = bottomSheet.layoutParams + layoutParams.height = WindowManager.LayoutParams.MATCH_PARENT + bottomSheet.layoutParams = layoutParams + } + + private fun setListWithInfinityScroll() { + binding.rvSchoolList.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.rvSchoolList.canScrollVertically(1) && + (layoutManager is LinearLayoutManager) && + (layoutManager.findLastVisibleItemPosition() == (adapter!!.itemCount - 1)) + ) { + viewModel.getUniversityList(inputText) + } + } + } + } + }) + } + + private fun setHideKeyboard() { + binding.layoutSchoolDialog.setOnSingleClickListener { + requireContext().hideKeyboard( + requireView(), + ) + } + } + private fun recyclerviewScroll() { + binding.rvSchoolList.addOnItemTouchListener(object : RecyclerView.OnItemTouchListener { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.action) { + MotionEvent.ACTION_MOVE -> { + binding.layoutSchoolDialog.requestDisallowInterceptTouchEvent(true) + } + } + return false + } + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {} + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} + }) + } + override fun onDestroyView() { + super.onDestroyView() + adapter = null + } + + companion object { + @JvmStatic + fun newInstance() = SearchDialogUniversityFragment() + + const val SCHOOL_FORM_URL = "https://bit.ly/46Yv0Hc" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/university/UniversityAdapter.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/university/UniversityAdapter.kt new file mode 100644 index 000000000..534908ebb --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/universityinfo/university/UniversityAdapter.kt @@ -0,0 +1,53 @@ +package com.el.yello.presentation.onboarding.fragment.universityinfo.university + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.el.yello.databinding.ItemUniversityListBinding +import com.example.ui.view.ItemDiffCallback +import com.example.ui.view.setOnSingleClickListener + +class UniversityAdapter( + private val storeUniversity: (String) -> Unit, +) : ListAdapter(diffUtil) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UniversityViewHolder { + return UniversityViewHolder( + ItemUniversityListBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ), + storeUniversity, + ) + } + + override fun onBindViewHolder(holder: UniversityViewHolder, position: Int) { + holder.setUniversity(getItem(position)) + } + + class UniversityViewHolder( + private val binding: ItemUniversityListBinding, + private val storeUniversity: (String) -> Unit, + ) : + RecyclerView.ViewHolder(binding.root) { + fun setUniversity(university: String) { + with(binding) { + universityList = university + root.setOnSingleClickListener { + storeUniversity(university) + } + tvSchoolName.setOnSingleClickListener { + storeUniversity(university) + } + } + } + } + + companion object { + private val diffUtil = ItemDiffCallback( + onItemsTheSame = { old, new -> old == new }, + onContentsTheSame = { old, new -> old == new }, + ) + } +} diff --git a/app/src/main/java/com/el/yello/presentation/onboarding/fragment/yelloid/YelIoIdFragment.kt b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/yelloid/YelIoIdFragment.kt new file mode 100644 index 000000000..ed3567264 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/onboarding/fragment/yelloid/YelIoIdFragment.kt @@ -0,0 +1,93 @@ +package com.el.yello.presentation.onboarding.fragment.yelloid + +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.el.yello.R +import com.el.yello.databinding.FragmentYelloIdBinding +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 YelIoIdFragment : BindingFragment(R.layout.fragment_yello_id) { + private val viewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + setDeleteBtnClickListener() + setYelloIdBtnClickListener() + observeGetValidYelloIdState() + } + + private fun setYelloIdBtnClickListener() { + binding.btnYelloIdNext.setOnSingleClickListener { + amplitudeYelloIdInfo() + viewModel.getValidYelloId(viewModel.id) + } + } + + private fun setDeleteBtnClickListener() { + binding.btnIdDelete.setOnClickListener { + binding.etId.text.clear() + } + } + + private fun observeGetValidYelloIdState() { + viewModel.getValidYelloIdState.observe(viewLifecycleOwner) { state -> + when (state) { + is UiState.Success -> { + if (state.data) { + initIdEditTextViewError() + return@observe + } + viewModel.resetGetValidYelloId() + findNavController().navigate(R.id.action_yelIoIdFragment_to_addFriendFragment) + val activity = requireActivity() as OnBoardingActivity + activity.progressBarPlus() + } + is UiState.Failure -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + is UiState.Loading -> {} + is UiState.Empty -> { + yelloSnackbar(binding.root, getString(R.string.msg_error)) + } + } + } + } + + private fun initIdEditTextViewError() { + with(binding) { + etId.setBackgroundResource(R.drawable.shape_fill_red20_line_semantic_status_red500_rect_8) + btnIdDelete.setBackgroundResource(R.drawable.ic_onboarding_delete_red) + tvIdErrorFirst.text = getString(R.string.onboarding_name_id_duplicate_id_msg) + tvIdErrorFirst.setTextColor(colorOf(R.color.semantic_red_500)) + tvIdErrorSecond.visibility = View.INVISIBLE + tvIdErrorThird.visibility = View.INVISIBLE + } + } + + private fun amplitudeYelloIdInfo() { + AmplitudeUtils.trackEventWithProperties( + EVENT_CLICK_ONBOARDING_NEXT, + JSONObject().put(NAME_ONBOARD_VIEW, VALUE_ID), + ) + AmplitudeUtils.updateUserProperties(PROPERTY_USER_ID, viewModel.id) + } + + companion object { + private const val EVENT_CLICK_ONBOARDING_NEXT = "click_onboarding_next" + private const val NAME_ONBOARD_VIEW = "onboard_view" + private const val VALUE_ID = "id" + private const val PROPERTY_USER_ID = "user_id" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/pay/BillingCallback.kt b/app/src/main/java/com/el/yello/presentation/pay/BillingCallback.kt new file mode 100644 index 000000000..b35b6dec5 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/BillingCallback.kt @@ -0,0 +1,9 @@ +package com.el.yello.presentation.pay + +import com.android.billingclient.api.Purchase + +interface BillingCallback { + fun onBillingConnected() // BillingClient 연결 성공 시 호출 + fun onSuccess(purchase: Purchase) // 구매 성공 시 호출 Purchase : 구매정보 + fun onFailure(responseCode: Int) // 구매 실패 시 호출 errorCode : BillingResponseCode +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/presentation/pay/BillingManager.kt b/app/src/main/java/com/el/yello/presentation/pay/BillingManager.kt new file mode 100644 index 000000000..9b0a03d11 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/BillingManager.kt @@ -0,0 +1,187 @@ +package com.el.yello.presentation.pay + +import android.app.Activity +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.BillingClient.ProductType.INAPP +import com.android.billingclient.api.BillingClient.ProductType.SUBS +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryProductDetailsParams.Product +import com.android.billingclient.api.QueryPurchasesParams +import com.android.billingclient.api.queryProductDetails +import com.el.yello.presentation.pay.PayActivity.Companion.YELLO_FIVE +import com.el.yello.presentation.pay.PayActivity.Companion.YELLO_ONE +import com.el.yello.presentation.pay.PayActivity.Companion.YELLO_PLUS +import com.el.yello.presentation.pay.PayActivity.Companion.YELLO_TWO +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import timber.log.Timber + +class BillingManager(private val activity: Activity, private val callback: BillingCallback) { + + private val _isPurchasing = MutableStateFlow(false) + val isPurchasing: StateFlow = _isPurchasing + + // 결제 시 작동하는 리스너 + private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + setIsPurchasing(true) + for (purchase in purchases) confirmPurchase(purchase) + } else { + callback.onFailure(billingResult.responseCode) + } + } + + // 결제 라이브러리 통신 위한 BillingClient 초기화 + val billingClient = + BillingClient.newBuilder(activity.applicationContext).setListener(purchasesUpdatedListener) + .enablePendingPurchases().build() + + // BillingClient을 결제 라이브러리에 연결 + init { + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + Timber.d("Billing Service Disconnected") + } + + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + callback.onBillingConnected() + } else { + callback.onFailure(billingResult.responseCode) + } + } + }) + } + + // 로딩 로티뷰 돌리기 위한 상태값 저장 초기화 + fun setIsPurchasing(boolean: Boolean) { + _isPurchasing.value = boolean + } + + // 결과로 받을 상품 정보 받아오기 + suspend fun getProductDetails(resultBlock: (List) -> Unit = {}) { + runBlocking { + val subsProducts = withContext(Dispatchers.IO) { getSubsProductDetails() } + val inAppProducts = withContext(Dispatchers.IO) { getInAppProductDetails() } + resultBlock(subsProducts + inAppProducts) + } + } + + private suspend fun getSubsProductDetails(): List { + val subsProductList = ArrayList().apply { + add( + Product.newBuilder().setProductId(YELLO_PLUS).setProductType(SUBS).build() + ) + } + val subsProductDetailList = withContext(Dispatchers.IO) { + billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder().setProductList(subsProductList).build() + ).productDetailsList + } ?: listOf() + return subsProductDetailList + } + + private suspend fun getInAppProductDetails(): List { + val inAppProductList = ArrayList().apply { + listOf(YELLO_ONE, YELLO_TWO, YELLO_FIVE).forEach { productId -> + add( + Product.newBuilder().setProductId(productId).setProductType(INAPP).build() + ) + } + } + val inAppProductDetailList = withContext(Dispatchers.IO) { + billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder().setProductList(inAppProductList).build() + ).productDetailsList + } ?: listOf() + return inAppProductDetailList + } + + // 구매 진행 + fun purchaseProduct(selectedOfferIndex: Int, productDetails: ProductDetails) { + val offerToken = + productDetails.subscriptionOfferDetails?.get(selectedOfferIndex)?.offerToken + + val productDetailsParamsList = listOf( + BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(productDetails) + .setOfferToken(offerToken ?: "").build() + ) + val billingFlowParams = + BillingFlowParams.newBuilder().setProductDetailsParamsList(productDetailsParamsList) + .build() + + val responseCode = billingClient.launchBillingFlow(activity, billingFlowParams).responseCode + if (responseCode != BillingClient.BillingResponseCode.OK) { + callback.onFailure(responseCode) + } + } + + // 구매 여부 확인 + private fun confirmPurchase(purchase: Purchase) { + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) { + // 구매를 완료 했지만 확인이 되지 않은 경우 확인 처리 + val ackPurchaseParams = + AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken) + + CoroutineScope(Dispatchers.Main).launch { + billingClient.acknowledgePurchase(ackPurchaseParams.build()) { + if (it.responseCode == BillingClient.BillingResponseCode.OK) { + consumePurchase(purchase) + } else { + callback.onFailure(it.responseCode) + } + } + } + } + } + + // 소비성 아이템 소비 완료 표시 (재구매 위해) + private fun consumePurchase(purchase: Purchase) { + if (purchase.products[0] != YELLO_PLUS) { + val consumeParams = + ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build() + + billingClient.consumeAsync(consumeParams) { billingResult, _ -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + checkConsumable(purchase) + } else { + callback.onFailure(billingResult.responseCode) + } + } + } else { + // 구독 상품의 경우, 소비 필요 없으므로 바로 success 이동 + callback.onSuccess(purchase) + } + } + + // 구매 표시 안된 소비성 아이템 찾아 소비 완료 다시 실행, 모두 완료 확인 후 성공 설정 + private fun checkConsumable(purchasedItem: Purchase) { + billingClient.queryPurchasesAsync( + QueryPurchasesParams.newBuilder().setProductType(ProductType.INAPP).build() + ) { billingResult, purchaseList -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + if (purchaseList.isNotEmpty()) { + purchaseList.forEach { purchase -> consumePurchase(purchase) } + } else { + callback.onSuccess(purchasedItem) + } + } else { + callback.onFailure(billingResult.responseCode) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/presentation/pay/PayActivity.kt b/app/src/main/java/com/el/yello/presentation/pay/PayActivity.kt new file mode 100644 index 000000000..28a2b6d0d --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/PayActivity.kt @@ -0,0 +1,354 @@ +package com.el.yello.presentation.pay + +import android.content.Intent +import android.graphics.Paint +import android.net.Uri +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.viewpager2.widget.ViewPager2 +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.el.yello.R +import com.el.yello.databinding.ActivityPayBinding +import com.el.yello.presentation.main.profile.manage.ProfileManageActivity +import com.el.yello.util.amplitude.AmplitudeUtils +import com.example.data.model.request.pay.toRequestPayModel +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.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.json.JSONObject +import timber.log.Timber + +@AndroidEntryPoint +class PayActivity : BindingActivity(R.layout.activity_pay) { + + private val viewModel by viewModels() + + private var _adapter: PayAdapter? = null + private val adapter + get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) } + + private var _manager: BillingManager? = null + private val manager + get() = requireNotNull(_manager) { getString(R.string.manager_not_initialized_error_msg) } + + private var productDetailsList = listOf() + + private var paySubsDialog: PaySubsDialog? = null + private var payInAppDialog: PayInAppDialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + initView() + initPurchaseBtnListener() + initBannerOnChangeListener() + viewModel.getPurchaseInfoFromServer() + setBannerAutoScroll() + setBillingManager() + observeIsPurchasing() + observePurchaseInfoState() + observeCheckSubsValidState() + observeCheckInAppValidState() + initPrivacyBtnListener() + initServiceBtnListener() + } + + private fun initView() { + _adapter = PayAdapter() + binding.vpBanner.adapter = adapter + binding.dotIndicator.setViewPager(binding.vpBanner) + binding.tvOriginalPrice.paintFlags = + binding.tvOriginalPrice.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG + } + + private fun initPurchaseBtnListener() { + binding.clSubscribe.setOnSingleClickListener { startPurchase(TYPE_PLUS, YELLO_PLUS) } + binding.clNameCheckOne.setOnSingleClickListener { startPurchase(TYPE_ONE, YELLO_ONE) } + binding.clNameCheckTwo.setOnSingleClickListener { startPurchase(TYPE_TWO, YELLO_TWO) } + binding.clNameCheckFive.setOnSingleClickListener { startPurchase(TYPE_FIVE, YELLO_FIVE) } + binding.ivBack.setOnSingleClickListener { finish() } + } + + private fun startPurchase(amplitude: String, productId: String) { + setClickShopBuyAmplitude(amplitude) + productDetailsList.withIndex().find { it.value.productId == productId } + ?.let { productDetails -> + manager.purchaseProduct(productDetails.index, productDetails.value) + } ?: also { + toast(getString(R.string.pay_error_no_item)) + } + } + + // BillingManager 설정 시 BillingClient 연결 & 콜백 응답 설정 -> 검증 진행 + private fun setBillingManager() { + _manager = BillingManager( + this, + object : BillingCallback { + override fun onBillingConnected() { + lifecycleScope.launch { + manager.getProductDetails() { list -> productDetailsList = list } + } + } + + override fun onSuccess(purchase: Purchase) { + if (purchase.products[0] == YELLO_PLUS) { + viewModel.checkSubsValidToServer(purchase.toRequestPayModel()) + } else { + viewModel.checkInAppValidToServer(purchase.toRequestPayModel()) + } + } + + override fun onFailure(responseCode: Int) { + Timber.d(responseCode.toString()) + } + }, + ) + } + + // 배너 자동 스크롤 로직 + private fun setBannerAutoScroll() { + lifecycleScope.launch { + while (true) { + delay(2500) + binding.vpBanner.currentItem.let { + binding.vpBanner.currentItem = it.plus(1) % 3 + } + } + } + } + + private fun initBannerOnChangeListener() { + binding.vpBanner.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + private var currentPosition = 0 + private var currentState = 0 + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + currentPosition = position + } + + override fun onPageScrollStateChanged(state: Int) { + super.onPageScrollStateChanged(state) + handleScrollState(state) + currentState = state + } + + private fun handleScrollState(state: Int) { + // 평소엔 1(DRAG), 2(SETTING), 0(IDLE) but 막혀있을 땐 1(DRAG), 0(IDLE), 2(SETTING) 이걸 이용 + if (state == ViewPager2.SCROLL_STATE_IDLE && currentState == ViewPager2.SCROLL_STATE_DRAGGING) { + setNextPage() + } + } + + private fun setNextPage() { + val lastPosition = 2 + if (currentPosition == 0) { + binding.vpBanner.currentItem = lastPosition + } else if (currentPosition == lastPosition) { + binding.vpBanner.currentItem = 0 + } + } + }) + } + + private fun observeIsPurchasing() { + manager.isPurchasing.flowWithLifecycle(lifecycle).onEach { isPurchasing -> + if (isPurchasing) startLoadingScreen() + }.launchIn(lifecycleScope) + } + + private fun observePurchaseInfoState() { + viewModel.getPurchaseInfoState.flowWithLifecycle(lifecycle).onEach { state -> + when (state) { + is UiState.Success -> { + binding.layoutShowSubs.isVisible = state.data?.isSubscribe == true + viewModel.setTicketCount(state.data?.ticketCount ?: 0) + } + + is UiState.Failure -> { + binding.layoutShowSubs.isVisible = false + } + + is UiState.Loading -> return@onEach + + is UiState.Empty -> return@onEach + } + }.launchIn(lifecycleScope) + } + + private fun observeCheckSubsValidState() { + viewModel.postSubsCheckState.flowWithLifecycle(lifecycle).onEach { state -> + stopLoadingScreen() + when (state) { + is UiState.Success -> { + setCompleteShopBuyAmplitude(TYPE_PLUS, PRICE_PLUS) + updateBuyDateAmplitude() + paySubsDialog = PaySubsDialog() + paySubsDialog?.show(supportFragmentManager, DIALOG_SUBS) + } + + is UiState.Failure -> { + stopLoadingScreen() + if (state.msg == SERVER_ERROR) { + showErrorDialog() + } else { + toast(getString(R.string.pay_check_error)) + } + } + + is UiState.Loading -> return@onEach + + is UiState.Empty -> return@onEach + } + }.launchIn(lifecycleScope) + } + + private fun observeCheckInAppValidState() { + viewModel.postInAppCheckState.flowWithLifecycle(lifecycle).onEach { state -> + when (state) { + is UiState.Success -> { + stopLoadingScreen() + when (state.data?.productId) { + YELLO_ONE -> { + setCompleteShopBuyAmplitude(TYPE_ONE, PRICE_ONE) + viewModel.addTicketCount(1) + } + + YELLO_TWO -> { + setCompleteShopBuyAmplitude(TYPE_TWO, PRICE_TWO) + viewModel.addTicketCount(2) + } + + YELLO_FIVE -> { + setCompleteShopBuyAmplitude(TYPE_FIVE, PRICE_FIVE) + viewModel.addTicketCount(5) + } + + else -> return@onEach + } + updateBuyDateAmplitude() + viewModel.currentInAppItem = state.data?.productId.toString() + payInAppDialog = PayInAppDialog() + payInAppDialog?.show(supportFragmentManager, DIALOG_IN_APP) + } + + is UiState.Failure -> { + stopLoadingScreen() + if (state.msg == SERVER_ERROR) { + showErrorDialog() + } else { + toast(getString(R.string.pay_check_error)) + } + } + + is UiState.Loading -> return@onEach + + is UiState.Empty -> return@onEach + } + }.launchIn(lifecycleScope) + } + + private fun initServiceBtnListener() { + binding.btnPayGuideService.setOnSingleClickListener { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(ProfileManageActivity.SERVICE_URL)), + ) + } + } + + private fun initPrivacyBtnListener() { + binding.btnPayGuidePrivacy.setOnSingleClickListener { + startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(ProfileManageActivity.PRIVACY_URL)), + ) + } + } + + private fun startLoadingScreen() { + binding.layoutPayCheckLoading.isVisible = true + this.window?.setFlags( + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + ) + } + + private fun stopLoadingScreen() { + manager.setIsPurchasing(false) + binding.layoutPayCheckLoading.isVisible = false + window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) + } + + // 서버 오류(500) 시 시스템 다이얼로그 띄우기 + private fun showErrorDialog() { + AlertDialog.Builder(this).setTitle(getString(R.string.pay_error_dialog_title)) + .setMessage(getString(R.string.pay_error_dialog_msg)) + .setPositiveButton(getString(R.string.pay_error_dialog_btn)) { dialog, _ -> dialog.dismiss() } + .create().show() + } + + private fun setClickShopBuyAmplitude(buyType: String) { + AmplitudeUtils.trackEventWithProperties( + "click_shop_buy", + JSONObject().put("buy_type", buyType), + ) + } + + private fun setCompleteShopBuyAmplitude(buyType: String, buyPrice: String) { + AmplitudeUtils.trackEventWithProperties( + "complete_shop_buy", + JSONObject().put("buy_type", buyType).put("buy_price", buyPrice), + ) + } + + private fun updateBuyDateAmplitude() { + AmplitudeUtils.setUserDataProperties("user_buy_date") + } + + override fun finish() { + intent.putExtra("ticketCount", viewModel.ticketCount) + setResult(RESULT_OK, intent) + super.finish() + } + + override fun onDestroy() { + super.onDestroy() + _adapter = null + _manager?.billingClient?.endConnection() + _manager = null + payInAppDialog?.dismiss() + paySubsDialog?.dismiss() + } + + companion object { + const val YELLO_PLUS = "yello_plus_subscribe" + const val YELLO_ONE = "yello_ticket_one" + const val YELLO_TWO = "yello_ticket_two" + const val YELLO_FIVE = "yello_ticket_five" + + const val TYPE_PLUS = "subscribe" + const val TYPE_ONE = "ticket1" + const val TYPE_TWO = "ticket2" + const val TYPE_FIVE = "ticket5" + + const val PRICE_PLUS = "2800" + const val PRICE_ONE = "990" + const val PRICE_TWO = "1900" + const val PRICE_FIVE = "3900" + + const val DIALOG_SUBS = "subsDialog" + const val DIALOG_IN_APP = "inAppDialog" + + const val SERVER_ERROR = "HTTP 500 " + } +} diff --git a/app/src/main/java/com/el/yello/presentation/pay/PayAdapter.kt b/app/src/main/java/com/el/yello/presentation/pay/PayAdapter.kt new file mode 100644 index 000000000..7e2e6c056 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/PayAdapter.kt @@ -0,0 +1,87 @@ +package com.el.yello.presentation.pay + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.el.yello.databinding.ItemPayFirstBinding +import com.el.yello.databinding.ItemPaySecondBinding +import com.el.yello.databinding.ItemPayThirdBinding + +class PayAdapter : RecyclerView.Adapter() { + + enum class Type { + ONE, + TWO, + THREE, + } + + override fun getItemViewType(position: Int): Int { + return when (position) { + 0 -> Type.ONE.ordinal + 1 -> Type.TWO.ordinal + else -> Type.THREE.ordinal + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + Type.ONE.ordinal -> { + val binding = ItemPayFirstBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + PayOneViewHolder(binding) + } + + Type.TWO.ordinal -> { + val binding = ItemPayThirdBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + PayThreeViewHolder(binding) + } + + else -> { + val binding = ItemPaySecondBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false, + ) + PayTwoViewHolder(binding) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is PayOneViewHolder -> holder.onBind() + is PayTwoViewHolder -> holder.onBind() + is PayThreeViewHolder -> holder.onBind() + } + } + + override fun getItemCount(): Int = 3 +} + +class PayOneViewHolder( + private val binding: ItemPayFirstBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun onBind() { + } +} + +class PayTwoViewHolder( + private val binding: ItemPaySecondBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun onBind() { + } +} + +class PayThreeViewHolder( + private val binding: ItemPayThirdBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun onBind() { + } +} diff --git a/app/src/main/java/com/el/yello/presentation/pay/PayInAppDialog.kt b/app/src/main/java/com/el/yello/presentation/pay/PayInAppDialog.kt new file mode 100644 index 000000000..f3da66639 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/PayInAppDialog.kt @@ -0,0 +1,76 @@ +package com.el.yello.presentation.pay + +import android.content.res.Resources +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.fragment.app.activityViewModels +import coil.load +import com.el.yello.R +import com.el.yello.databinding.FragmentPayInAppDialogBinding +import com.el.yello.presentation.pay.PayActivity.Companion.YELLO_FIVE +import com.el.yello.presentation.pay.PayActivity.Companion.YELLO_ONE +import com.el.yello.presentation.pay.PayActivity.Companion.YELLO_TWO +import com.example.ui.base.BindingDialogFragment +import com.example.ui.view.setOnSingleClickListener + +class PayInAppDialog : + BindingDialogFragment(R.layout.fragment_pay_in_app_dialog) { + + private val viewModel by activityViewModels() + + override fun onStart() { + super.onStart() + setDialogBackground() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initConfirmBtnListener() + setDialogByItem() + } + + 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) + } + + private fun initConfirmBtnListener() { + binding.btnPayConfirm.setOnSingleClickListener { + dismiss() + (activity as PayActivity).finish() + } + } + + private fun setDialogByItem() { + when (viewModel.currentInAppItem) { + YELLO_ONE -> { + binding.tvPayDialogSubtitle1.text = getString(R.string.pay_dialog_in_app_title_1) + binding.ivPayInApp.load(R.drawable.ic_pay_in_app_1) + } + + YELLO_TWO -> { + binding.tvPayDialogSubtitle1.text = getString(R.string.pay_dialog_in_app_title_2) + binding.ivPayInApp.load(R.drawable.ic_pay_in_app_2) + } + + YELLO_FIVE -> { + binding.tvPayDialogSubtitle1.text = getString(R.string.pay_dialog_in_app_title_5) + binding.ivPayInApp.load(R.drawable.ic_pay_in_app_5) + } + + else -> dismiss() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/presentation/pay/PayReSubsNoticeDialog.kt b/app/src/main/java/com/el/yello/presentation/pay/PayReSubsNoticeDialog.kt new file mode 100644 index 000000000..233ea8786 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/PayReSubsNoticeDialog.kt @@ -0,0 +1,75 @@ +package com.el.yello.presentation.pay + +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import com.el.yello.R +import com.el.yello.databinding.FragmentNoticeResubscribeBinding +import com.example.ui.base.BindingDialogFragment +import com.example.ui.view.setOnSingleClickListener + +class PayReSubsNoticeDialog : + BindingDialogFragment(R.layout.fragment_notice_resubscribe) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setNoticeBtnClickListener() + getArgExpiredDate() + } + + override fun onStart() { + super.onStart() + showDialogFullScreen() + } + + private fun showDialogFullScreen() { + dialog?.window?.setLayout( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + + private fun setNoticeBtnClickListener() { + binding.btnNoticeQuit.setOnSingleClickListener { + dismiss() + } + binding.btnYelloplusSubscribe.setOnSingleClickListener { + Intent(requireContext(), PayActivity::class.java).apply { + startActivity(this) + } + dismiss() + } + } + + private fun setExpiredDate(expiredDate: String) { + if (isAdded) { + binding.tvResubscribeExpiredDate.text = expiredDate + } + } + + private fun getArgExpiredDate() { + val expiredDate = arguments?.getString(ARG_EXPIRED_DATE) + expiredDate?.let { + setExpiredDate(it) + } + } + + companion object { + private const val ARG_EXPIRED_DATE = "arg_expired_date" + + @JvmStatic + fun newInstance(expiredDate: String): PayReSubsNoticeDialog { + return PayReSubsNoticeDialog().apply { + arguments = Bundle().apply { + putString(ARG_EXPIRED_DATE, expiredDate) + } + } + } + } +} diff --git a/app/src/main/java/com/el/yello/presentation/pay/PaySubsDialog.kt b/app/src/main/java/com/el/yello/presentation/pay/PaySubsDialog.kt new file mode 100644 index 000000000..7f99f4108 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/PaySubsDialog.kt @@ -0,0 +1,47 @@ +package com.el.yello.presentation.pay + +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.FragmentPaySubsDialogBinding +import com.example.ui.base.BindingDialogFragment +import com.example.ui.view.setOnSingleClickListener + +class PaySubsDialog : + BindingDialogFragment(R.layout.fragment_pay_subs_dialog) { + + override fun onStart() { + super.onStart() + setDialogBackground() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initConfirmBtnListener() + } + + 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) + } + + private fun initConfirmBtnListener() { + binding.btnPayConfirm.setOnSingleClickListener { + dismiss() + (activity as PayActivity).finish() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/presentation/pay/PayViewModel.kt b/app/src/main/java/com/el/yello/presentation/pay/PayViewModel.kt new file mode 100644 index 000000000..94278dd5c --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/pay/PayViewModel.kt @@ -0,0 +1,82 @@ +package com.el.yello.presentation.pay + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.domain.entity.PayInAppModel +import com.example.domain.entity.PayInfoModel +import com.example.domain.entity.PayRequestModel +import com.example.domain.entity.PaySubsModel +import com.example.domain.repository.PayRepository +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 javax.inject.Inject + +@HiltViewModel +class PayViewModel @Inject constructor( + private val payRepository: PayRepository, +) : ViewModel() { + + var currentInAppItem = String() + + private val _postSubsCheckState = MutableStateFlow>(UiState.Empty) + val postSubsCheckState: StateFlow> = _postSubsCheckState + + private val _postInAppCheckState = MutableStateFlow>(UiState.Empty) + val postInAppCheckState: StateFlow> = _postInAppCheckState + + private val _getPurchaseInfoState = MutableStateFlow>(UiState.Empty) + val getPurchaseInfoState: StateFlow> = _getPurchaseInfoState + + var ticketCount = 0 + private set + + fun setTicketCount(count: Int) { + ticketCount = count + } + + fun addTicketCount(count: Int) { + ticketCount += count + } + + fun checkSubsValidToServer(request: PayRequestModel) { + viewModelScope.launch { + _postSubsCheckState.value = UiState.Loading + payRepository.postToCheckSubs(request) + .onSuccess { + _postSubsCheckState.value = UiState.Success(it) + } + .onFailure { + _postSubsCheckState.value = UiState.Failure(it.message.toString()) + } + } + } + + fun checkInAppValidToServer(request: PayRequestModel) { + viewModelScope.launch { + _postInAppCheckState.value = UiState.Loading + payRepository.postToCheckInApp(request) + .onSuccess { + _postInAppCheckState.value = UiState.Success(it) + } + .onFailure { + _postInAppCheckState.value = UiState.Failure(it.message.toString()) + } + } + } + + fun getPurchaseInfoFromServer() { + viewModelScope.launch { + payRepository.getPurchaseInfo() + .onSuccess { + it ?: return@launch + _getPurchaseInfoState.value = UiState.Success(it) + } + .onFailure { + _getPurchaseInfoState.value = UiState.Failure(it.message.toString()) + } + } + } +} diff --git a/app/src/main/java/com/el/yello/presentation/search/SearchActivity.kt b/app/src/main/java/com/el/yello/presentation/search/SearchActivity.kt new file mode 100644 index 000000000..12c507760 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/search/SearchActivity.kt @@ -0,0 +1,214 @@ +package com.el.yello.presentation.search + +import android.content.Context +import android.os.Bundle +import android.view.View +import android.view.animation.AnimationUtils +import android.view.inputmethod.InputMethodManager +import androidx.activity.viewModels +import androidx.core.view.isVisible +import androidx.core.widget.doAfterTextChanged +import androidx.core.widget.doOnTextChanged +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.ActivitySearchBinding +import com.el.yello.util.Utils.setPullToScrollColor +import com.el.yello.util.amplitude.AmplitudeUtils +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.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class SearchActivity : BindingActivity(R.layout.activity_search) { + + private var _adapter: SearchAdapter? = null + private val adapter + get() = requireNotNull(_adapter) { getString(R.string.adapter_not_initialized_error_msg) } + + private val viewModel by viewModels() + + private var searchJob: Job? = null + + private var searchText: String = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + initAdapter() + initFocusToEditText() + initBackBtnListener() + setPullToScrollListener() + setLoadingScreenWhenTyping() + setDebounceSearch() + observeSearchListState() + observeAddFriendState() + setListWithInfinityScroll() + } + + private fun initAdapter() { + _adapter = SearchAdapter { searchFriendModel, position, holder -> + viewModel.setPositionAndHolder(position, holder) + viewModel.addFriendToServer(searchFriendModel.id.toLong()) + AmplitudeUtils.trackEventWithProperties("click_search_addfriend") + } + binding.rvRecommendSearch.adapter = adapter + binding.rvRecommendSearch.addItemDecoration(SearchItemDecoration(this)) + } + + // 처음 들어왔을 때 키보드 올라오도록 설정 (개인정보보호옵션 켜진 경우 불가능) + private fun initFocusToEditText() { + val inputMethodManager = + getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + binding.etRecommendSearchBox.requestFocus() + inputMethodManager.showSoftInput( + binding.etRecommendSearchBox, InputMethodManager.SHOW_IMPLICIT + ) + } + + private fun initBackBtnListener() { + binding.btnRecommendSearchBack.setOnSingleClickListener { finish() } + } + + private fun setPullToScrollListener() { + binding.layoutSearchSwipe.apply { + setOnRefreshListener { + lifecycleScope.launch { + showShimmerView(isLoading = true, hasList = true) + viewModel.setFriendsListFromServer(searchText) + delay(300) + showShimmerView(isLoading = false, hasList = true) + binding.layoutSearchSwipe.isRefreshing = false + } + } + setPullToScrollColor(R.color.grayscales_500, R.color.grayscales_700) + } + } + + private fun setLoadingScreenWhenTyping() { + binding.etRecommendSearchBox.doOnTextChanged { _, _, _, _ -> + lifecycleScope.launch { + showShimmerView(isLoading = true, hasList = true) + viewModel.setNewPage() + adapter.submitList(listOf()) + adapter.notifyDataSetChanged() + } + } + } + + private fun setDebounceSearch() { + binding.etRecommendSearchBox.doAfterTextChanged { text -> + searchJob?.cancel() + if (text.isNullOrBlank()) { + showShimmerView(isLoading = false, hasList = false) + binding.layoutRecommendNoSearch.visibility = View.GONE + } else { + searchJob = viewModel.viewModelScope.launch { + delay(debounceTime) + text.toString().let { text -> + searchText = text + viewModel.setFriendsListFromServer(text) + } + } + } + } + } + + private fun observeSearchListState() { + viewModel.postFriendsListState.flowWithLifecycle(lifecycle) + .onEach { state -> + when (state) { + is UiState.Success -> { + if (viewModel.isFirstLoading) { + startFadeIn() + viewModel.isFirstLoading = false + } + if (state.data.friendList.isEmpty()) { + showShimmerView(isLoading = false, hasList = false) + } else { + adapter.addList(state.data.friendList) + showShimmerView(isLoading = false, hasList = true) + } + } + + is UiState.Failure -> { + toast(getString(R.string.recommend_search_error)) + showShimmerView(isLoading = false, hasList = true) + } + + is UiState.Loading -> return@onEach + + is UiState.Empty -> return@onEach + } + }.launchIn(lifecycleScope) + } + + private fun setListWithInfinityScroll() { + binding.rvRecommendSearch.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.rvRecommendSearch.canScrollVertically(1) && layoutManager is LinearLayoutManager && layoutManager.findLastVisibleItemPosition() == adapter.itemCount - 1) { + viewModel.setFriendsListFromServer(searchText) + } + } + } + } + }) + } + + 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) { + holder.binding.btnRecommendItemAdd.visibility = View.GONE + holder.binding.btnRecommendItemMyFriend.visibility = View.VISIBLE + } + } + + is UiState.Failure -> toast(getString(R.string.recommend_error_add_friend_connection)) + + is UiState.Loading -> return@onEach + + is UiState.Empty -> return@onEach + } + }.launchIn(lifecycleScope) + } + + private fun startFadeIn() { + val animation = AnimationUtils.loadAnimation(this, R.anim.fade_in) + binding.rvRecommendSearch.startAnimation(animation) + } + + private fun showShimmerView(isLoading: Boolean, hasList: Boolean) { + with(binding) { + layoutSearchSwipe.isVisible = !isLoading + layoutRecommendSearchLoading.isVisible = isLoading + layoutRecommendNoSearch.isVisible = !hasList + } + } + + override fun onDestroy() { + super.onDestroy() + _adapter = null + searchJob?.cancel() + } + + companion object { + const val debounceTime = 500L + } +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/presentation/search/SearchAdapter.kt b/app/src/main/java/com/el/yello/presentation/search/SearchAdapter.kt new file mode 100644 index 000000000..1a6551694 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/search/SearchAdapter.kt @@ -0,0 +1,38 @@ +package com.el.yello.presentation.search + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import com.el.yello.databinding.ItemRecommendSearchBinding +import com.example.domain.entity.SearchListModel.SearchFriendModel +import com.example.ui.view.ItemDiffCallback + +class SearchAdapter( + private val itemClick: (SearchFriendModel, Int, SearchViewHolder) -> Unit +) : ListAdapter(diffUtil) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchViewHolder { + val inflater by lazy { LayoutInflater.from(parent.context) } + val binding: ItemRecommendSearchBinding = + ItemRecommendSearchBinding.inflate(inflater, parent, false) + return SearchViewHolder(binding, itemClick) + } + + override fun onBindViewHolder(holder: SearchViewHolder, position: Int) { + val item = getItem(position) ?: return + holder.onBind(item, position) + } + + fun addList(newItems: List) { + val currentItems = currentList.toMutableList() + currentItems.addAll(newItems) + submitList(currentItems) + } + + 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/search/SearchItemDecoration.kt b/app/src/main/java/com/el/yello/presentation/search/SearchItemDecoration.kt new file mode 100644 index 000000000..5e43b9d5a --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/search/SearchItemDecoration.kt @@ -0,0 +1,54 @@ +package com.el.yello.presentation.search + +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 SearchItemDecoration(context: Context) : RecyclerView.ItemDecoration() { + private val dividerHeight = 1.dpToPx(context) + private val dividerMargin = 8.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/search/SearchViewHolder.kt b/app/src/main/java/com/el/yello/presentation/search/SearchViewHolder.kt new file mode 100644 index 000000000..83dde7e3a --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/search/SearchViewHolder.kt @@ -0,0 +1,31 @@ +package com.el.yello.presentation.search + +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.el.yello.databinding.ItemRecommendSearchBinding +import com.el.yello.util.Utils.setImageOrBasicThumbnail +import com.example.domain.entity.SearchListModel.SearchFriendModel +import com.example.ui.view.setOnSingleClickListener + +class SearchViewHolder( + val binding: ItemRecommendSearchBinding, + private val itemClick: (SearchFriendModel, Int, SearchViewHolder) -> Unit, +) : RecyclerView.ViewHolder(binding.root) { + + fun onBind(item: SearchFriendModel, position: Int) { + with(binding) { + tvRecommendItemName.text = item.name + tvRecommendItemId.text = String.format("@%s", item.yelloId) + tvRecommendItemSchool.text = item.group + + ivRecommendItemThumbnail.setImageOrBasicThumbnail(item.profileImage) + + btnRecommendItemAdd.isVisible = !item.isFriend + btnRecommendItemMyFriend.isVisible = item.isFriend + + btnRecommendItemAdd.setOnSingleClickListener { + itemClick(item, position, this@SearchViewHolder) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/presentation/search/SearchViewModel.kt b/app/src/main/java/com/el/yello/presentation/search/SearchViewModel.kt new file mode 100644 index 000000000..c1a1cb1fd --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/search/SearchViewModel.kt @@ -0,0 +1,81 @@ +package com.el.yello.presentation.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.domain.entity.SearchListModel +import com.example.domain.repository.RecommendRepository +import com.example.domain.repository.SearchRepository +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 javax.inject.Inject +import kotlin.math.ceil + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val recommendRepository: RecommendRepository, + private val searchRepository: SearchRepository, +) : ViewModel() { + + private val _postFriendsListState = MutableStateFlow>(UiState.Empty) + val postFriendsListState: StateFlow> = _postFriendsListState + + private val _addFriendState = MutableStateFlow>(UiState.Empty) + val addFriendState: StateFlow> = _addFriendState + + private var currentPage = -1 + private var isPagingFinish = false + private var totalPage = Int.MAX_VALUE + + var isFirstLoading = true + + var itemPosition: Int? = null + var itemHolder: SearchViewHolder? = null + + fun setPositionAndHolder(position: Int, holder: SearchViewHolder) { + itemPosition = position + itemHolder = holder + } + + fun setNewPage() { + currentPage = -1 + isPagingFinish = false + totalPage = Int.MAX_VALUE + isFirstLoading = true + _postFriendsListState.value = UiState.Empty + } + + fun setFriendsListFromServer(keyword: String) { + if (isPagingFinish) return + viewModelScope.launch { + searchRepository.getSearchList( + ++currentPage, + keyword + ) + .onSuccess { + it ?: return@launch + totalPage = ceil((it.totalCount * 0.1)).toInt() - 1 + if (totalPage == currentPage) isPagingFinish = true + _postFriendsListState.value = UiState.Success(it) + } + .onFailure { t -> + _postFriendsListState.value = UiState.Failure(t.message.toString()) + } + } + } + + fun addFriendToServer(friendId: Long) { + _addFriendState.value = UiState.Loading + viewModelScope.launch { + recommendRepository.postFriendAdd(friendId) + .onSuccess { + _addFriendState.value = UiState.Success(it) + } + .onFailure { t -> + _addFriendState.value = UiState.Failure(t.message.toString()) + } + } + } +} diff --git a/app/src/main/java/com/el/yello/presentation/splash/SplashActivity.kt b/app/src/main/java/com/el/yello/presentation/splash/SplashActivity.kt new file mode 100644 index 000000000..116b82ea1 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/splash/SplashActivity.kt @@ -0,0 +1,146 @@ +package com.el.yello.presentation.splash + +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog +import com.el.yello.BuildConfig +import com.el.yello.R +import com.el.yello.databinding.ActivitySplashBinding +import com.el.yello.presentation.auth.SignInActivity +import com.el.yello.presentation.main.MainActivity +import com.el.yello.util.NetworkManager +import com.example.ui.base.BindingActivity +import com.example.ui.context.toast +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.model.AppUpdateType.IMMEDIATE +import com.google.android.play.core.install.model.UpdateAvailability +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber + +@AndroidEntryPoint +class SplashActivity : BindingActivity(R.layout.activity_splash) { + private val viewModel by viewModels() + + private val appUpdateManager by lazy { AppUpdateManagerFactory.create(this) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + initAppUpdate() + } + + private fun initAppUpdate() { + if (NetworkManager.checkNetworkState(this)) { + if (BuildConfig.DEBUG) { + initSplash() + } else { + val appUpdateInfoTask = appUpdateManager.appUpdateInfo + appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) { + appUpdateInfo.isUpdateTypeAllowed(IMMEDIATE) + requestUpdate(appUpdateInfo) + } else { + initSplash() + } + }.addOnFailureListener { + initSplash() + } + } + } else { + AlertDialog.Builder(this) + .setTitle("안내") + .setMessage("인터넷 연결을 확인해주세요.") + .setCancelable(false) + .setPositiveButton( + "확인", + DialogInterface.OnClickListener { dialog, _ -> + finishAffinity() + }, + ) + .create() + .show() + } + } + + private fun initSplash() { + Handler(Looper.getMainLooper()).postDelayed({ + if (viewModel.getIsAutoLogin()) { + navigateToMainScreen() + } else { + navigateToSignInScreen() + } + }, 3000) + } + + private fun requestUpdate(appUpdateInfo: AppUpdateInfo) { + runCatching { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activityResultLauncher, + AppUpdateOptions.newBuilder(IMMEDIATE) + .setAllowAssetPackDeletion(true) + .build(), + ) + }.onFailure { + Timber.e(it) + } + } + + private fun navigateToMainScreen() { + var type: String? = "" + var path: String? = "" + if (intent.extras != null) { + type = intent.getStringExtra("type") + path = intent.getStringExtra("path") + } + + MainActivity.getIntent(this, type, path).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(this) + } + finish() + } + + private fun navigateToSignInScreen() { + Intent(this, SignInActivity::class.java).apply { + startActivity(this) + } + finish() + } + + private val activityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { + if (it.resultCode != RESULT_OK) { + toast(getString(R.string.splash_update_error)) + finishAffinity() + } + } + + override fun onResume() { + super.onResume() + + if (!BuildConfig.DEBUG) { + appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo -> + if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) { + runCatching { + appUpdateManager.startUpdateFlowForResult( + appUpdateInfo, + activityResultLauncher, + AppUpdateOptions.newBuilder(IMMEDIATE) + .build(), + ) + }.onFailure { + Timber.e(it) + } + } + } + } + } +} diff --git a/app/src/main/java/com/el/yello/presentation/splash/SplashViewModel.kt b/app/src/main/java/com/el/yello/presentation/splash/SplashViewModel.kt new file mode 100644 index 000000000..a0fe59a45 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/splash/SplashViewModel.kt @@ -0,0 +1,13 @@ +package com.el.yello.presentation.splash + +import androidx.lifecycle.ViewModel +import com.example.domain.repository.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + fun getIsAutoLogin(): Boolean = authRepository.getAutoLogin() +} diff --git a/app/src/main/java/com/el/yello/presentation/tutorial/TutorialAActivity.kt b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialAActivity.kt new file mode 100644 index 000000000..759dede48 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialAActivity.kt @@ -0,0 +1,62 @@ +package com.el.yello.presentation.tutorial + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import com.el.yello.R +import com.el.yello.databinding.ActivityTutorialABinding +import com.el.yello.presentation.onboarding.activity.OnBoardingActivity.Companion.EXTRA_CODE_TEXT_EMPTY +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 +import org.json.JSONObject + +class TutorialAActivity : BindingActivity(R.layout.activity_tutorial_a) { + + private val isFromOnBoarding by boolExtra() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + amplitudeATutorial() + setClickListener() + } + + override fun onPause() { + super.onPause() + overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION) + } + + private fun setClickListener() { + binding.root.setOnSingleClickListener { + val isCodeTextEmpty = intent.getBooleanExtra(EXTRA_CODE_TEXT_EMPTY, false) + val intent = Intent(this@TutorialAActivity, TutorialBActivity::class.java).apply { + putExtra(EXTRA_CODE_TEXT_EMPTY, isCodeTextEmpty) + putExtra(EXTRA_FROM_ONBOARDING, isFromOnBoarding) + } + startActivity(intent) + finish() + } + } + + private fun amplitudeATutorial() { + AmplitudeUtils.trackEventWithProperties( + EVENT_VIEW_ONBOARDING_TUTORIAL, + JSONObject().put(NAME_TUTORIAL_STEP, VALUE_TUTORIAL_ONE), + ) + } + + companion object { + @JvmStatic + fun newIntent(context: Context, isFromOnBoarding: Boolean) = + Intent(context, TutorialAActivity::class.java).apply { + putExtra("isFromOnBoarding", isFromOnBoarding) + } + + const val EXTRA_FROM_ONBOARDING = "isFromOnBoarding" + private const val NONE_ANIMATION = 0 + private const val EVENT_VIEW_ONBOARDING_TUTORIAL = "view_onboarding_tutorial" + private const val NAME_TUTORIAL_STEP = "tutorial_step" + private const val VALUE_TUTORIAL_ONE = "1" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/tutorial/TutorialBActivity.kt b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialBActivity.kt new file mode 100644 index 000000000..c4d0741e9 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialBActivity.kt @@ -0,0 +1,53 @@ +package com.el.yello.presentation.tutorial + +import android.content.Intent +import android.os.Bundle +import com.el.yello.R +import com.el.yello.databinding.ActivityTutorialBBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingActivity.Companion.EXTRA_CODE_TEXT_EMPTY +import com.el.yello.presentation.tutorial.TutorialAActivity.Companion.EXTRA_FROM_ONBOARDING +import com.el.yello.util.amplitude.AmplitudeUtils +import com.example.ui.base.BindingActivity +import com.example.ui.view.setOnSingleClickListener +import org.json.JSONObject + +class TutorialBActivity : BindingActivity(R.layout.activity_tutorial_b) { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + amplitudeBTutorial() + setClickListener() + } + + override fun onPause() { + super.onPause() + overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION) + } + + private fun setClickListener() { + binding.root.setOnSingleClickListener { + val isCodeTextEmpty = intent.getBooleanExtra(EXTRA_CODE_TEXT_EMPTY, false) + val isFromOnBoarding = intent.getBooleanExtra(EXTRA_FROM_ONBOARDING, false) + + val intent = Intent(this@TutorialBActivity, TutorialCActivity::class.java).apply { + putExtra(EXTRA_CODE_TEXT_EMPTY, isCodeTextEmpty) + putExtra(EXTRA_FROM_ONBOARDING, isFromOnBoarding) + } + startActivity(intent) + finish() + } + } + + private fun amplitudeBTutorial() { + AmplitudeUtils.trackEventWithProperties( + EVENT_VIEW_ONBOARDING_TUTORIAL, + JSONObject().put(NAME_TUTORIAL_STEP, VALUE_TUTORIAL_TWO), + ) + } + + companion object { + private const val NONE_ANIMATION = 0 + private const val EVENT_VIEW_ONBOARDING_TUTORIAL = "view_onboarding_tutorial" + private const val NAME_TUTORIAL_STEP = "tutorial_step" + private const val VALUE_TUTORIAL_TWO = "2" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/tutorial/TutorialCActivity.kt b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialCActivity.kt new file mode 100644 index 000000000..b74d89314 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialCActivity.kt @@ -0,0 +1,54 @@ +package com.el.yello.presentation.tutorial + +import android.content.Intent +import android.os.Bundle +import com.el.yello.R +import com.el.yello.databinding.ActivityTutorialCBinding +import com.el.yello.presentation.onboarding.activity.OnBoardingActivity +import com.el.yello.util.amplitude.AmplitudeUtils +import com.example.ui.base.BindingActivity +import com.example.ui.view.setOnSingleClickListener +import org.json.JSONObject + +class TutorialCActivity : BindingActivity(R.layout.activity_tutorial_c) { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + amplitudeCTutorial() + setClickListener() + } + + override fun onPause() { + super.onPause() + overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION) + } + + private fun setClickListener() { + binding.root.setOnSingleClickListener { + val isCodeTextEmpty = intent.getBooleanExtra(OnBoardingActivity.EXTRA_CODE_TEXT_EMPTY, false) + val isFromOnBoarding = + intent.getBooleanExtra(TutorialAActivity.EXTRA_FROM_ONBOARDING, false) + + val intent = Intent(this@TutorialCActivity, TutorialDActivity::class.java).apply { + putExtra(OnBoardingActivity.EXTRA_CODE_TEXT_EMPTY, isCodeTextEmpty) + putExtra(TutorialAActivity.EXTRA_FROM_ONBOARDING, isFromOnBoarding) + } + startActivity(intent) + finish() + } + } + + private fun amplitudeCTutorial() { + AmplitudeUtils.trackEventWithProperties( + EVENT_VIEW_ONBOARDING_TUTORIAL, + JSONObject().put(NAME_TUTORIAL_STEP, VALUE_TUTORIAL_THREE), + ) + } + + companion object { + private const val NONE_ANIMATION = 0 + private const val EVENT_VIEW_ONBOARDING_TUTORIAL = "view_onboarding_tutorial" + private const val NAME_TUTORIAL_STEP = "tutorial_step" + private const val VALUE_TUTORIAL_THREE = "3" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/tutorial/TutorialDActivity.kt b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialDActivity.kt new file mode 100644 index 000000000..c423dfbeb --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialDActivity.kt @@ -0,0 +1,62 @@ +package com.el.yello.presentation.tutorial + +import android.content.Intent +import android.os.Bundle +import com.el.yello.R +import com.el.yello.databinding.ActivityTutorialDBinding +import com.el.yello.presentation.main.MainActivity +import com.el.yello.presentation.onboarding.activity.OnBoardingActivity +import com.el.yello.util.amplitude.AmplitudeUtils +import com.example.ui.base.BindingActivity +import com.example.ui.view.setOnSingleClickListener +import org.json.JSONObject + +class TutorialDActivity : BindingActivity(R.layout.activity_tutorial_d) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setClickListener() + amplitudeDTutorial() + } + + override fun onPause() { + super.onPause() + overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION) + } + + private fun setClickListener() { + val isCodeTextEmpty = intent.getBooleanExtra(OnBoardingActivity.EXTRA_CODE_TEXT_EMPTY, false) + val isFromOnBoarding = intent.getBooleanExtra(TutorialAActivity.EXTRA_FROM_ONBOARDING, false) + binding.root.setOnSingleClickListener { + if (isFromOnBoarding) { + if (isCodeTextEmpty) { + val intent = Intent(this@TutorialDActivity, TutorialEndActivity::class.java) + startActivity(intent) + } else { + val intent = Intent(this@TutorialDActivity, TutorialEndPlusActivity::class.java) + startActivity(intent) + } + } else { + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(this) + } + } + finish() + } + } + + private fun amplitudeDTutorial() { + AmplitudeUtils.trackEventWithProperties( + EVENT_VIEW_ONBOARDING_TUTORIAL, + JSONObject().put(NAME_TUTORIAL_STEP, VALUE_TUTORIAL_FOUR), + ) + } + + companion object { + private const val NONE_ANIMATION = 0 + private const val EVENT_VIEW_ONBOARDING_TUTORIAL = "view_onboarding_tutorial" + private const val NAME_TUTORIAL_STEP = "tutorial_step" + private const val VALUE_TUTORIAL_FOUR = "4" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/tutorial/TutorialEndActivity.kt b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialEndActivity.kt new file mode 100644 index 000000000..769119aba --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialEndActivity.kt @@ -0,0 +1,36 @@ +package com.el.yello.presentation.tutorial + +import android.content.Intent +import android.os.Bundle +import com.el.yello.R +import com.el.yello.databinding.ActivityTutorialEndPointBinding +import com.el.yello.presentation.main.MainActivity +import com.el.yello.util.amplitude.AmplitudeUtils +import com.example.ui.base.BindingActivity +import com.example.ui.view.setOnSingleClickListener + +class TutorialEndActivity : + BindingActivity(R.layout.activity_tutorial_end_point) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initEndClickListener() + } + + override fun onPause() { + super.onPause() + overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION) + } + private fun initEndClickListener() { + binding.btnEndTutorial.setOnSingleClickListener { + AmplitudeUtils.trackEventWithProperties(EVENT_CLICK_ONBOARDING_YELLO_START) + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + } + } + + companion object { + private const val NONE_ANIMATION = 0 + private const val EVENT_CLICK_ONBOARDING_YELLO_START = "click_onboarding_yellostart" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/tutorial/TutorialEndPlusActivity.kt b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialEndPlusActivity.kt new file mode 100644 index 000000000..61b57e4bb --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/tutorial/TutorialEndPlusActivity.kt @@ -0,0 +1,37 @@ +package com.el.yello.presentation.tutorial + +import android.content.Intent +import android.os.Bundle +import com.el.yello.R +import com.el.yello.databinding.ActivityTutorialEndPluspointBinding +import com.el.yello.presentation.main.MainActivity +import com.el.yello.util.amplitude.AmplitudeUtils +import com.example.ui.base.BindingActivity +import com.example.ui.view.setOnSingleClickListener + +class TutorialEndPlusActivity : + BindingActivity(R.layout.activity_tutorial_end_pluspoint) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initEndPlusClickListener() + } + + override fun onPause() { + super.onPause() + overridePendingTransition(NONE_ANIMATION, NONE_ANIMATION) + } + + private fun initEndPlusClickListener() { + binding.btnEndTutorial.setOnSingleClickListener { + AmplitudeUtils.trackEventWithProperties(EVENT_CLICK_ONBOARDING_YELLO_START) + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + } + } + + companion object { + private const val NONE_ANIMATION = 0 + private const val EVENT_CLICK_ONBOARDING_YELLO_START = "click_onboarding_yellostart" + } +} diff --git a/app/src/main/java/com/el/yello/presentation/util/BaseLinearRcvItemDeco.kt b/app/src/main/java/com/el/yello/presentation/util/BaseLinearRcvItemDeco.kt new file mode 100644 index 000000000..ea1545e2c --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/util/BaseLinearRcvItemDeco.kt @@ -0,0 +1,82 @@ +package com.el.yello.presentation.util + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class BaseLinearRcvItemDeco( + private val top: Int = 0, + private val bottom: Int = 0, + private val right: Int = 0, + private val left: Int = 0, + private val startPadding: Int = 0, + private val orientation: Int = RecyclerView.VERTICAL, + private val bottomPadding: Int = 0, +) : RecyclerView.ItemDecoration() { + + constructor( + top: Int, + bottom: Int, + right: Int, + left: Int, + startPadding: Int, + orientation: Int, + ) : this(top, bottom, right, left, startPadding, orientation, -1) + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + super.getItemOffsets(outRect, view, parent, state) + + // 현재 아이템의 포지션 가져오기 + val position = parent.getChildAdapterPosition(view) + val itemCount = state.itemCount + + if (orientation == RecyclerView.VERTICAL) { + outRect.right = right.dp + outRect.left = left.dp + + when (position) { + 0 -> { + outRect.top = startPadding.dp + outRect.bottom = bottom.dp / 2 + } + + itemCount - 1 -> { + outRect.top = top.dp / 2 + outRect.bottom = if (bottomPadding >= 0) bottomPadding.dp else bottom.dp + } + + else -> { + outRect.top = top.dp / 2 + outRect.bottom = bottom.dp / 2 + } + } + } else if (orientation == RecyclerView.HORIZONTAL) { + outRect.top = top.dp + outRect.bottom = bottom.dp + + when (position) { + 0 -> { + outRect.left = startPadding.dp + outRect.right = right.dp / 2 + } + + itemCount - 1 -> { + outRect.left = left.dp / 2 + if (bottomPadding >= 0) { + outRect.right = bottomPadding.dp + } + } + + else -> { + outRect.right = right.dp / 2 + outRect.left = left.dp / 2 + } + } + } + } +} diff --git a/app/src/main/java/com/el/yello/presentation/util/GradientButton.kt b/app/src/main/java/com/el/yello/presentation/util/GradientButton.kt new file mode 100644 index 000000000..59041c34b --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/util/GradientButton.kt @@ -0,0 +1,55 @@ +package com.el.yello.presentation.util + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatButton +import androidx.core.content.ContextCompat +import com.el.yello.R +import com.google.android.material.button.MaterialButton + +/** + * made 2023.08.07 + * leekangmim + */ +class GradientButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatButton(context, attrs, defStyleAttr) { + + init { + setupBackground() + setupAnimation() + } + + private fun setupBackground() { + val colors = intArrayOf(ContextCompat.getColor(context, R.color.animate_start), ContextCompat.getColor(context, R.color.animate_center), ContextCompat.getColor(context, R.color.animate_end)) + val gradientDrawable = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + colors + ) + gradientDrawable.cornerRadius = 100.dp.toFloat() + gradientDrawable.setStroke(2.dp, ContextCompat.getColor(context, R.color.animate_start)) // 초기 테두리 색상 + + background = gradientDrawable + } + + private fun setupAnimation() { + val animator = ObjectAnimator.ofArgb(this, "strokeColor", ContextCompat.getColor(context, R.color.animate_end), ContextCompat.getColor(context, R.color.white), ContextCompat.getColor(context, R.color.animate_end)) + animator.duration = 2000 // 애니메이션 지속시간 + animator.repeatCount = ValueAnimator.INFINITE // 무한 반복 + + animator.start() + } + + // strokeColor 속성을 위한 setter 메서드 + fun setStrokeColor(color: Int) { + val backgroundDrawable = background as? GradientDrawable + backgroundDrawable?.setStroke(2.dp, color) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/presentation/util/GradientStrokeButton.kt b/app/src/main/java/com/el/yello/presentation/util/GradientStrokeButton.kt new file mode 100644 index 000000000..95fa2b70f --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/util/GradientStrokeButton.kt @@ -0,0 +1,102 @@ +package com.el.yello.presentation.util + +import android.content.Context +import android.graphics.Canvas +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Shader +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatButton +import androidx.core.content.ContextCompat +import androidx.core.content.withStyledAttributes +import com.el.yello.R + +/** + * made 2023.08.07 + * leekangmim + */ +class GradientStrokeButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatButton(context, attrs, defStyleAttr) { + + private var cornerRadius = 0f + private var borderWidth = 0f + private var startColor = 0 + private var centerColor = 0 + private var endColor = 0 + + private val path = Path() + private val borderPaint = Paint().apply { + style = Paint.Style.FILL + } + + init { + //Get the values you set in xml + context.withStyledAttributes(attrs, R.styleable.StyledButton) { + borderWidth = getDimension(R.styleable.StyledButton_borderWidth, 2.dp.toFloat()) + cornerRadius = getDimension(R.styleable.StyledButton_cornerRadius, 100.dp.toFloat()) + startColor = getColor( + R.styleable.StyledButton_startColor, + ContextCompat.getColor(context, R.color.animate_start) + ) + centerColor = getColor( + R.styleable.StyledButton_centerColor, + ContextCompat.getColor(context, R.color.animate_center) + ) + endColor = getColor( + R.styleable.StyledButton_endColor, + ContextCompat.getColor(context, R.color.animate_end) + ) + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + // Create and set your gradient here so that the gradient size is always correct + borderPaint.shader = LinearGradient( + 45f, + 45f, + width.toFloat(), + height.toFloat(), + intArrayOf(startColor, centerColor, endColor), + null, + Shader.TileMode.CLAMP + ) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + //Remove inner section (that you require to be transparent) from canvas + path.rewind() + path.addRoundRect( + borderWidth, + borderWidth, + width.toFloat() - borderWidth, + height.toFloat() - borderWidth, + cornerRadius - borderWidth / 2, + cornerRadius - borderWidth / 2, + Path.Direction.CCW + ) + canvas.clipOutPath(path) + + //Draw gradient on the outer section + path.rewind() + path.addRoundRect( + 0f, + 0f, + width.toFloat(), + height.toFloat(), + cornerRadius, + cornerRadius, + Path.Direction.CCW + ) + canvas.drawPath(path, borderPaint) + } +} + + diff --git a/app/src/main/java/com/el/yello/presentation/util/ResolutionMetrics.kt b/app/src/main/java/com/el/yello/presentation/util/ResolutionMetrics.kt new file mode 100644 index 000000000..4335153f7 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/util/ResolutionMetrics.kt @@ -0,0 +1,43 @@ +package com.el.yello.presentation.util + +import android.app.Application +import androidx.annotation.Px +import com.el.yello.MyApp +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlin.math.roundToInt + +class ResolutionMetrics @Inject constructor( + @ApplicationContext private val application: Application, +) { + private val displayMetrics + get() = application.resources.displayMetrics + + val screenWidth + get() = displayMetrics.widthPixels + + val screenHeight + get() = displayMetrics.heightPixels + + val screenShort + get() = screenWidth.coerceAtMost(screenHeight) + + val screenLong + get() = screenWidth.coerceAtLeast(screenHeight) + + @Px + fun toPixel(dp: Int) = (dp * displayMetrics.density).roundToInt() + fun toDP(@Px pixel: Int) = (pixel / displayMetrics.density).roundToInt() +} + +val Number.pixel: Int + @Px get() = MyApp.resolutionMetrics.toDP(this.toInt()) + +val Number.dp: Int + get() = MyApp.resolutionMetrics.toPixel(this.toInt()) + +val Number.pixelFloat: Float + @Px get() = MyApp.resolutionMetrics.toDP(this.toInt()).toFloat() + +val Number.dpFloat: Float + get() = MyApp.resolutionMetrics.toPixel(this.toInt()).toFloat() diff --git a/app/src/main/java/com/el/yello/presentation/util/ViewPagerExt.kt b/app/src/main/java/com/el/yello/presentation/util/ViewPagerExt.kt new file mode 100644 index 000000000..9b5ba871a --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/util/ViewPagerExt.kt @@ -0,0 +1,42 @@ +package com.el.yello.presentation.util + +import android.animation.Animator +import android.animation.TimeInterpolator +import android.animation.ValueAnimator +import android.view.animation.AccelerateDecelerateInterpolator +import androidx.viewpager2.widget.ViewPager2 + +fun ViewPager2.setCurrentItemWithDuration( + item: Int, + duration: Long, + interpolator: TimeInterpolator = AccelerateDecelerateInterpolator(), + pagePxWidth: Int = width, // Default value taken from getWidth() from ViewPager2 view +) { + val pxToDrag: Int = pagePxWidth * (item - currentItem) + val animator = ValueAnimator.ofInt(0, pxToDrag) + var previousValue = 0 + animator.addUpdateListener { valueAnimator -> + val currentValue = valueAnimator.animatedValue as Int + val currentPxToDrag = (currentValue - previousValue).toFloat() + fakeDragBy(-currentPxToDrag) + previousValue = currentValue + } + animator.addListener(object : Animator.AnimatorListener { + override fun onAnimationStart(animation: Animator) { + beginFakeDrag() + } + + override fun onAnimationEnd(animation: Animator) { + endFakeDrag() + } + + override fun onAnimationCancel(animation: Animator) { /* Ignored */ + } + + override fun onAnimationRepeat(animation: Animator) { /* Ignored */ + } + }) + animator.interpolator = interpolator + animator.duration = duration + animator.start() +} diff --git a/app/src/main/java/com/el/yello/presentation/util/YelloAnimationButton.kt b/app/src/main/java/com/el/yello/presentation/util/YelloAnimationButton.kt new file mode 100644 index 000000000..ac1b2f215 --- /dev/null +++ b/app/src/main/java/com/el/yello/presentation/util/YelloAnimationButton.kt @@ -0,0 +1,52 @@ +package com.el.yello.presentation.util + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatButton +import androidx.core.content.ContextCompat +import com.el.yello.R + +/** + * made 2023.08.07 + * leekangmim + */ +class YelloAnimationButton @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AppCompatButton(context, attrs, defStyleAttr) { + + init { + setupBackground() + setupAnimation() + } + + private fun setupBackground() { + val colors = intArrayOf(ContextCompat.getColor(context, R.color.yello_main_500), ContextCompat.getColor(context, R.color.yello_main_500)) + val gradientDrawable = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + colors + ) + gradientDrawable.cornerRadius = 100.dp.toFloat() + gradientDrawable.setStroke(2.dp, ContextCompat.getColor(context, R.color.white)) // 초기 테두리 색상 + + background = gradientDrawable + } + + private fun setupAnimation() { + val animator = ObjectAnimator.ofArgb(this, "strokeColor", ContextCompat.getColor(context, R.color.white), ContextCompat.getColor(context, R.color.transparent), ContextCompat.getColor(context, R.color.white)) + animator.duration = 2000 // 애니메이션 지속시간 + animator.repeatCount = ValueAnimator.INFINITE // 무한 반복 + + animator.start() + } + + // strokeColor 속성을 위한 setter 메서드 + fun setStrokeColor(color: Int) { + val backgroundDrawable = background as? GradientDrawable + backgroundDrawable?.setStroke(2.dp, color) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/el/yello/util/NetworkManager.kt b/app/src/main/java/com/el/yello/util/NetworkManager.kt new file mode 100644 index 000000000..3406d5426 --- /dev/null +++ b/app/src/main/java/com/el/yello/util/NetworkManager.kt @@ -0,0 +1,20 @@ +package com.el.yello.util + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities + +object NetworkManager { + fun checkNetworkState(context: Context): Boolean { + val connectivityManager: ConnectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val network = connectivityManager.activeNetwork ?: return false + val actNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false + return when { + actNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + actNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + else -> false + } + } +} diff --git a/app/src/main/java/com/el/yello/util/Utils.kt b/app/src/main/java/com/el/yello/util/Utils.kt new file mode 100644 index 000000000..75cce70ca --- /dev/null +++ b/app/src/main/java/com/el/yello/util/Utils.kt @@ -0,0 +1,42 @@ +package com.el.yello.util + +import android.widget.ImageView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import coil.load +import coil.transform.CircleCropTransformation +import com.el.yello.R +import com.example.ui.context.colorOf + +object Utils { + fun setChosungText(name: String, number: Int): String { + val firstChosung = name[number] + val chosungUnicode = Character.UnicodeBlock.of(firstChosung.toInt()) + return if (chosungUnicode == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO || + chosungUnicode == Character.UnicodeBlock.HANGUL_JAMO || + chosungUnicode == Character.UnicodeBlock.HANGUL_SYLLABLES + ) { + val chosungIndex = (firstChosung.toInt() - 0xAC00) / 28 / 21 + val chosung = Character.toChars(chosungIndex + 0x1100)[0] + chosung.toString() // 출력: "ㄱ" + } else { + "" + } + } + + fun SwipeRefreshLayout.setPullToScrollColor(arrowColor: Int, bgColor: Int) { + setColorSchemeColors(context.colorOf(arrowColor)) + setProgressBackgroundColorSchemeColor(context.colorOf(bgColor)) + } + + fun ImageView.setImageOrBasicThumbnail(thumbnail: String) { + this.apply { + load(if (thumbnail == URL_BASIC_THUMBNAIL) R.drawable.img_yello_basic else thumbnail) { + transformations(CircleCropTransformation()) + } + } + } + + private const val URL_BASIC_THUMBNAIL = + "https://k.kakaocdn.net/dn/dpk9l1/btqmGhA2lKL/Oz0wDuJn1YV2DIn92f6DVK/img_640x640.jpg" + +} diff --git a/app/src/main/java/com/el/yello/util/amplitude/Amplitude.kt b/app/src/main/java/com/el/yello/util/amplitude/Amplitude.kt new file mode 100644 index 000000000..54ff1cb04 --- /dev/null +++ b/app/src/main/java/com/el/yello/util/amplitude/Amplitude.kt @@ -0,0 +1,36 @@ +package com.el.yello.util.amplitude + +import com.amplitude.api.Amplitude +import com.amplitude.api.Identify +import org.json.JSONObject +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +object AmplitudeUtils { + + private val amplitude = Amplitude.getInstance() + + fun trackEventWithProperties(eventName: String, properties: JSONObject? = null) { + if (properties == null) { + amplitude.logEvent(eventName) + } else { + amplitude.logEvent(eventName, properties) + } + } + + fun updateUserProperties(propertyName: String, values: String) { + val identify = Identify().set(propertyName, values) + amplitude.identify(identify) + } + + fun updateUserIntProperties(propertyName: String, values: Int) { + val identify = Identify().set(propertyName, values) + amplitude.identify(identify) + } + + fun setUserDataProperties(propertyName: String) { + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + val identify = Identify().setOnce(propertyName, LocalDateTime.now().format(formatter)) + amplitude.identify(identify) + } +} diff --git a/app/src/main/java/com/el/yello/util/binding/BindingAdapter.kt b/app/src/main/java/com/el/yello/util/binding/BindingAdapter.kt new file mode 100644 index 000000000..939da8fdd --- /dev/null +++ b/app/src/main/java/com/el/yello/util/binding/BindingAdapter.kt @@ -0,0 +1,189 @@ +package com.el.yello.util.binding + +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat.getColor +import androidx.core.view.isVisible +import androidx.databinding.BindingAdapter +import com.airbnb.lottie.LottieAnimationView +import com.el.yello.R +import com.example.ui.context.setMargins + +object BindingAdapter { + @JvmStatic + @BindingAdapter("setVoteBackground") + fun ConstraintLayout.setVoteBackground(bgIndex: Int) { + setBackgroundResource( + when (bgIndex % 12) { + 0 -> R.drawable.shape_gradient_vote_bg_01 + 1 -> R.drawable.shape_gradient_vote_bg_02 + 2 -> R.drawable.shape_gradient_vote_bg_03 + 3 -> R.drawable.shape_gradient_vote_bg_04 + 4 -> R.drawable.shape_gradient_vote_bg_05 + 5 -> R.drawable.shape_gradient_vote_bg_06 + 6 -> R.drawable.shape_gradient_vote_bg_07 + 7 -> R.drawable.shape_gradient_vote_bg_08 + 8 -> R.drawable.shape_gradient_vote_bg_09 + 9 -> R.drawable.shape_gradient_vote_bg_10 + 10 -> R.drawable.shape_gradient_vote_bg_11 + 11 -> R.drawable.shape_gradient_vote_bg_12 + else -> throw IndexOutOfBoundsException("vote bg index out of bounds : $bgIndex") + }, + ) + } + + @JvmStatic + @BindingAdapter("selectedIndex", "optionIndex") + fun TextView.setNameTextColor(selectedIndex: Int?, optionIndex: Int) { + if (selectedIndex == null) { + setTextColor(context.getColor(R.color.white)) + return + } + + if (selectedIndex == optionIndex) { + setTextColor(context.getColor(R.color.yello_main_500)) + return + } + + setTextColor(context.getColor(R.color.grayscales_700)) + } + + @JvmStatic + @BindingAdapter("selectedId", "optionId") + fun TextView.setYelloIdTextColor(selectedId: Int?, optionId: Int) { + if (selectedId != null && selectedId != optionId) { + setTextColor(context.getColor(R.color.grayscales_800)) + return + } + setTextColor(context.getColor(R.color.grayscales_600)) + } + + @JvmStatic + @BindingAdapter("selectedKeyword", "optionKeyword") + fun TextView.setKeywordTextColor(selectedKeyword: String?, optionKeyword: String) { + if (selectedKeyword == null) { + setTextColor(context.getColor(R.color.white)) + return + } + + if (selectedKeyword == optionKeyword) { + setTextColor(context.getColor(R.color.yello_main_500)) + return + } + + setTextColor(context.getColor(R.color.grayscales_700)) + } + + @JvmStatic + @BindingAdapter("setDrawableTint") + fun TextView.setDrawableTint(disabled: Boolean) { + val color = + if (disabled) { + getColor(context, R.color.gray_66) + } else { + getColor( + context, + R.color.black, + ) + } + for (drawable in compoundDrawables) { + if (drawable != null) { + drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) + } + } + } + + @JvmStatic + @BindingAdapter("setBalloonSrc") + fun LottieAnimationView.setBalloonSrc(index: Int) { + setAnimation( + when (index) { + 0 -> R.raw.lottie_note_balloon1 + 1 -> R.raw.lottie_note_balloon2 + 2 -> R.raw.lottie_note_balloon3 + 3 -> R.raw.lottie_note_balloon4 + 4 -> R.raw.lottie_note_balloon5 + 5 -> R.raw.lottie_note_balloon6 + 6 -> R.raw.lottie_note_balloon7 + else -> R.raw.lottie_note_balloon8 + }, + ) + playAnimation() + } + + @JvmStatic + @BindingAdapter("setFaceSrc") + fun ImageView.setFaceSrc(index: Int) { + setImageResource( + when (index) { + 0 -> R.drawable.img_note_face1 + 1 -> R.drawable.img_note_face2 + 2 -> R.drawable.img_note_face3 + 3 -> R.drawable.img_note_face4 + 4 -> R.drawable.img_note_face5 + 5 -> R.drawable.img_note_face6 + 6 -> R.drawable.img_note_face7 + 7 -> R.drawable.img_note_face8 + 8 -> R.drawable.img_note_face9 + else -> R.drawable.img_note_face10 + }, + ) + } + + @JvmStatic + @BindingAdapter("convertToMinAndSec") + fun TextView.convertToMinAndSec(left: Long) { + val minutes = left / 60 + val seconds = left % 60 + text = String.format(context.getString(R.string.wait_time_format), minutes, seconds) + } + + @JvmStatic + @BindingAdapter("setNullOrBlankVisible") + fun TextView.setNullOrBlankVisible(text: String?) { + this.isVisible = !text.isNullOrBlank() + } + + @JvmStatic + @BindingAdapter("setImageTint") + fun ImageView.setImageTint(colorIndex: Int) { + if (colorIndex == 1 || colorIndex == 3 || colorIndex == 7) { + this.setColorFilter(getColor(this.context, R.color.black)) + } + } + + @JvmStatic + @BindingAdapter("setTextTint") + fun TextView.setTextTint(colorIndex: Int) { + if (colorIndex == 1 || colorIndex == 3 || colorIndex == 7) { + this.setTextColor(getColor(this.context, R.color.black)) + } + } + + @JvmStatic + @BindingAdapter("android:layout_weight") + fun View.setLayoutWeight(weight: Int) { + val layoutParams = layoutParams as? LinearLayout.LayoutParams + layoutParams?.let { + it.weight = weight.toFloat() + this.layoutParams = it + } + } + + @JvmStatic + @BindingAdapter("android:layout_marginTop") + fun View.setLayoutMarginTop(margin: Int) { + setMargins( + view = this, + left = 0, + top = margin, + right = 0, + bottom = 0, + ) + } +} diff --git a/app/src/main/java/com/el/yello/util/context/ContextExt.kt b/app/src/main/java/com/el/yello/util/context/ContextExt.kt new file mode 100644 index 000000000..06effcd6c --- /dev/null +++ b/app/src/main/java/com/el/yello/util/context/ContextExt.kt @@ -0,0 +1,14 @@ +package com.el.yello.util.context + +import android.content.Context +import android.view.View +import androidx.fragment.app.Fragment +import com.el.yello.util.view.YelloSnackbar + +fun Context.yelloSnackbar(anchorView: View, message: String) { + YelloSnackbar.make(anchorView, message) +} + +fun Fragment.yelloSnackbar(anchorView: View, message: String) { + YelloSnackbar.make(anchorView, message).show() +} diff --git a/app/src/main/java/com/el/yello/util/view/YelloSnackbar.kt b/app/src/main/java/com/el/yello/util/view/YelloSnackbar.kt new file mode 100644 index 000000000..43f616f49 --- /dev/null +++ b/app/src/main/java/com/el/yello/util/view/YelloSnackbar.kt @@ -0,0 +1,48 @@ +package com.el.yello.util.view + +import android.view.LayoutInflater +import android.view.View +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import com.el.yello.R +import com.el.yello.databinding.LayoutYelloSnackbarBinding +import com.google.android.material.snackbar.Snackbar + +class YelloSnackbar(view: View, private val message: String) { + private val context = view.context + private val snackbar = Snackbar.make(view, "", DURATION_YELLO_SNACKBAR) + private val snackbarLayout = snackbar.view as Snackbar.SnackbarLayout + + private val inflater = LayoutInflater.from(context) + private val snackbarBinding: LayoutYelloSnackbarBinding = + DataBindingUtil.inflate(inflater, R.layout.layout_yello_snackbar, null, false) + + init { + initView() + initData() + } + + private fun initView() { + with(snackbarLayout) { + removeAllViews() + setPadding(0, 0, 0, 0) + setBackgroundColor(ContextCompat.getColor(context, android.R.color.transparent)) + addView(snackbarBinding.root, 0) + } + } + + private fun initData() { + snackbarBinding.tvSnackbar.text = message + } + + fun show() { + snackbar.show() + } + + companion object { + private const val DURATION_YELLO_SNACKBAR = 1500 + + @JvmStatic + fun make(view: View, message: String) = YelloSnackbar(view, message) + } +} diff --git a/app/src/main/java/com/el/yello/util/view/YelloStartShadowView.kt b/app/src/main/java/com/el/yello/util/view/YelloStartShadowView.kt new file mode 100644 index 000000000..fadd89c38 --- /dev/null +++ b/app/src/main/java/com/el/yello/util/view/YelloStartShadowView.kt @@ -0,0 +1,48 @@ +package com.el.yello.util.view + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RectF +import android.util.AttributeSet +import android.view.View + +class YelloStartShadowView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : View(context, attrs, defStyleAttr) { + private val rect = RectF() + + private val eraser = Paint().apply { + isAntiAlias = true + xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR) + } + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + + drawHole(requireNotNull(canvas)) + } + + private fun drawHole(canvas: Canvas) { + val holeBorderRadius = width / 2f + + canvas.drawCircle(holeBorderRadius, holeBorderRadius, holeBorderRadius, eraser) + canvas.drawRect( + rect.apply { + setRect() + }, + eraser, + ) + } + + private fun setRect() { + val holeWidth = width + val holeHeight = width / 2f + + rect.set(0f, 0f, holeWidth.toFloat(), holeHeight) + } +} diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml new file mode 100644 index 000000000..247717311 --- /dev/null +++ b/app/src/main/res/anim/fade_in.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 000000000..884ee5422 --- /dev/null +++ b/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_instagram.png b/app/src/main/res/drawable-hdpi/ic_instagram.png new file mode 100644 index 000000000..512911f33 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_instagram.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_instagram.png b/app/src/main/res/drawable-mdpi/ic_instagram.png new file mode 100644 index 000000000..fbc696bdd Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_instagram.png differ diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_instagram.png b/app/src/main/res/drawable-xhdpi/ic_instagram.png new file mode 100644 index 000000000..0caffb9b7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_instagram.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_instagram.png b/app/src/main/res/drawable-xxhdpi/ic_instagram.png new file mode 100644 index 000000000..b10eee843 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_instagram.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_instagram.png b/app/src/main/res/drawable-xxxhdpi/ic_instagram.png new file mode 100644 index 000000000..4c662b644 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_instagram.png differ diff --git a/app/src/main/res/drawable/bg_yello_wait.xml b/app/src/main/res/drawable/bg_yello_wait.xml new file mode 100644 index 000000000..75fb4944a --- /dev/null +++ b/app/src/main/res/drawable/bg_yello_wait.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_alert.xml b/app/src/main/res/drawable/ic_alert.xml new file mode 100644 index 000000000..64afebd91 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_alert_yellow.xml b/app/src/main/res/drawable/ic_alert_yellow.xml new file mode 100644 index 000000000..284997497 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert_yellow.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_left.xml b/app/src/main/res/drawable/ic_arrow_left.xml new file mode 100644 index 000000000..02ba600da --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_left.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/ic_arrow_upward.xml b/app/src/main/res/drawable/ic_arrow_upward.xml new file mode 100644 index 000000000..46d5d587d --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_upward.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..50472f215 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_dot.xml b/app/src/main/res/drawable/ic_dot.xml new file mode 100644 index 000000000..2ed3d636e --- /dev/null +++ b/app/src/main/res/drawable/ic_dot.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 000000000..84059187d --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_exit.xml b/app/src/main/res/drawable/ic_exit.xml new file mode 100644 index 000000000..13fc6b941 --- /dev/null +++ b/app/src/main/res/drawable/ic_exit.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gender_check_female.xml b/app/src/main/res/drawable/ic_gender_check_female.xml new file mode 100644 index 000000000..166231977 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_check_female.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gender_check_male.xml b/app/src/main/res/drawable/ic_gender_check_male.xml new file mode 100644 index 000000000..cbb644fd8 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_check_male.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gender_check_unselected.xml b/app/src/main/res/drawable/ic_gender_check_unselected.xml new file mode 100644 index 000000000..98fb7487f --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_check_unselected.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_gender_female_face.xml b/app/src/main/res/drawable/ic_gender_female_face.xml new file mode 100644 index 000000000..53b56a590 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_female_face.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_gender_male_face.xml b/app/src/main/res/drawable/ic_gender_male_face.xml new file mode 100644 index 000000000..27eae92f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_male_face.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_gender_selected_face.xml b/app/src/main/res/drawable/ic_gender_selected_face.xml new file mode 100644 index 000000000..6ffda9687 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_selected_face.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_gender_unselected_face.xml b/app/src/main/res/drawable/ic_gender_unselected_face.xml new file mode 100644 index 000000000..9402c7c33 --- /dev/null +++ b/app/src/main/res/drawable/ic_gender_unselected_face.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_kakao.xml b/app/src/main/res/drawable/ic_kakao.xml new file mode 100644 index 000000000..beecf8a01 --- /dev/null +++ b/app/src/main/res/drawable/ic_kakao.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_kakao_share.xml b/app/src/main/res/drawable/ic_kakao_share.xml new file mode 100644 index 000000000..27c18c3af --- /dev/null +++ b/app/src/main/res/drawable/ic_kakao_share.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_key.xml b/app/src/main/res/drawable/ic_key.xml new file mode 100644 index 000000000..e8bdec34b --- /dev/null +++ b/app/src/main/res/drawable/ic_key.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_key_1.xml b/app/src/main/res/drawable/ic_key_1.xml new file mode 100644 index 000000000..a3d9ffaeb --- /dev/null +++ b/app/src/main/res/drawable/ic_key_1.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_key_2.xml b/app/src/main/res/drawable/ic_key_2.xml new file mode 100644 index 000000000..43b626a7d --- /dev/null +++ b/app/src/main/res/drawable/ic_key_2.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_key_5.xml b/app/src/main/res/drawable/ic_key_5.xml new file mode 100644 index 000000000..a11706751 --- /dev/null +++ b/app/src/main/res/drawable/ic_key_5.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_link_share.xml b/app/src/main/res/drawable/ic_link_share.xml new file mode 100644 index 000000000..c82845904 --- /dev/null +++ b/app/src/main/res/drawable/ic_link_share.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_lock.xml b/app/src/main/res/drawable/ic_lock.xml new file mode 100644 index 000000000..b49398b54 --- /dev/null +++ b/app/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_logo.xml b/app/src/main/res/drawable/ic_logo.xml new file mode 100644 index 000000000..bc82865a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_look_right_arrow.xml b/app/src/main/res/drawable/ic_look_right_arrow.xml new file mode 100644 index 000000000..bb7e5765a --- /dev/null +++ b/app/src/main/res/drawable/ic_look_right_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_main_look_selected.xml b/app/src/main/res/drawable/ic_main_look_selected.xml new file mode 100644 index 000000000..4af51824c --- /dev/null +++ b/app/src/main/res/drawable/ic_main_look_selected.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_main_look_unselected.xml b/app/src/main/res/drawable/ic_main_look_unselected.xml new file mode 100644 index 000000000..f57206354 --- /dev/null +++ b/app/src/main/res/drawable/ic_main_look_unselected.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_main_my_yello_selected.xml b/app/src/main/res/drawable/ic_main_my_yello_selected.xml new file mode 100644 index 000000000..61f17c81a --- /dev/null +++ b/app/src/main/res/drawable/ic_main_my_yello_selected.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_main_my_yello_unselected.xml b/app/src/main/res/drawable/ic_main_my_yello_unselected.xml new file mode 100644 index 000000000..cc646ee32 --- /dev/null +++ b/app/src/main/res/drawable/ic_main_my_yello_unselected.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_main_profile_selected.xml b/app/src/main/res/drawable/ic_main_profile_selected.xml new file mode 100644 index 000000000..b96335a13 --- /dev/null +++ b/app/src/main/res/drawable/ic_main_profile_selected.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_main_profile_unselected.xml b/app/src/main/res/drawable/ic_main_profile_unselected.xml new file mode 100644 index 000000000..5ba2a25ae --- /dev/null +++ b/app/src/main/res/drawable/ic_main_profile_unselected.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_main_recommend_selected.xml b/app/src/main/res/drawable/ic_main_recommend_selected.xml new file mode 100644 index 000000000..19c9f1415 --- /dev/null +++ b/app/src/main/res/drawable/ic_main_recommend_selected.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_main_recommend_unselected.xml b/app/src/main/res/drawable/ic_main_recommend_unselected.xml new file mode 100644 index 000000000..8bff9c25f --- /dev/null +++ b/app/src/main/res/drawable/ic_main_recommend_unselected.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_main_yello_active.xml b/app/src/main/res/drawable/ic_main_yello_active.xml new file mode 100644 index 000000000..8e8328cd7 --- /dev/null +++ b/app/src/main/res/drawable/ic_main_yello_active.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_main_yello_selected.xml b/app/src/main/res/drawable/ic_main_yello_selected.xml new file mode 100644 index 000000000..66311e95d --- /dev/null +++ b/app/src/main/res/drawable/ic_main_yello_selected.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_main_yello_unselected.xml b/app/src/main/res/drawable/ic_main_yello_unselected.xml new file mode 100644 index 000000000..270eeafdd --- /dev/null +++ b/app/src/main/res/drawable/ic_main_yello_unselected.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_no_search.xml b/app/src/main/res/drawable/ic_no_search.xml new file mode 100644 index 000000000..9db297aba --- /dev/null +++ b/app/src/main/res/drawable/ic_no_search.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_note_shuffle.xml b/app/src/main/res/drawable/ic_note_shuffle.xml new file mode 100644 index 000000000..5665014b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_shuffle.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_note_skip.xml b/app/src/main/res/drawable/ic_note_skip.xml new file mode 100644 index 000000000..45f33cd87 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_skip.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notice_check.xml b/app/src/main/res/drawable/ic_notice_check.xml new file mode 100644 index 000000000..f9f4119bc --- /dev/null +++ b/app/src/main/res/drawable/ic_notice_check.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notice_resubscribe_exit.xml b/app/src/main/res/drawable/ic_notice_resubscribe_exit.xml new file mode 100644 index 000000000..53b693629 --- /dev/null +++ b/app/src/main/res/drawable/ic_notice_resubscribe_exit.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notice_subscribe_one.xml b/app/src/main/res/drawable/ic_notice_subscribe_one.xml new file mode 100644 index 000000000..01dfe5cf4 --- /dev/null +++ b/app/src/main/res/drawable/ic_notice_subscribe_one.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notice_subscribe_three.xml b/app/src/main/res/drawable/ic_notice_subscribe_three.xml new file mode 100644 index 000000000..a922d00c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_notice_subscribe_three.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notice_subscribe_two.xml b/app/src/main/res/drawable/ic_notice_subscribe_two.xml new file mode 100644 index 000000000..73e5e9ab5 --- /dev/null +++ b/app/src/main/res/drawable/ic_notice_subscribe_two.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_notice_uncheck.xml b/app/src/main/res/drawable/ic_notice_uncheck.xml new file mode 100644 index 000000000..d04d96c6a --- /dev/null +++ b/app/src/main/res/drawable/ic_notice_uncheck.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_onboarding_addfriend_uncheck.xml b/app/src/main/res/drawable/ic_onboarding_addfriend_uncheck.xml new file mode 100644 index 000000000..820ff9eb7 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_addfriend_uncheck.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_btn_back.xml b/app/src/main/res/drawable/ic_onboarding_btn_back.xml new file mode 100644 index 000000000..02ba600da --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_btn_back.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_delete.xml b/app/src/main/res/drawable/ic_onboarding_delete.xml new file mode 100644 index 000000000..94c2a954a --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_delete.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_delete_red.xml b/app/src/main/res/drawable/ic_onboarding_delete_red.xml new file mode 100644 index 000000000..f654b1cbd --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_delete_red.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_edit_name_bubble.xml b/app/src/main/res/drawable/ic_onboarding_edit_name_bubble.xml new file mode 100644 index 000000000..ff16099b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_edit_name_bubble.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_friend_check.xml b/app/src/main/res/drawable/ic_onboarding_friend_check.xml new file mode 100644 index 000000000..1ce573525 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_friend_check.xml @@ -0,0 +1,19 @@ + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_id_fix.xml b/app/src/main/res/drawable/ic_onboarding_id_fix.xml new file mode 100644 index 000000000..0b0a1ae3d --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_id_fix.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_onboarding_name_yello_bubble.xml b/app/src/main/res/drawable/ic_onboarding_name_yello_bubble.xml new file mode 100644 index 000000000..a8b409b1d --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_name_yello_bubble.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_onboarding_school_yello_bubble.xml b/app/src/main/res/drawable/ic_onboarding_school_yello_bubble.xml new file mode 100644 index 000000000..527728948 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_school_yello_bubble.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_schoollist_search.xml b/app/src/main/res/drawable/ic_onboarding_schoollist_search.xml new file mode 100644 index 000000000..709ffae1e --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_schoollist_search.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_search.xml b/app/src/main/res/drawable/ic_onboarding_search.xml new file mode 100644 index 000000000..30fdfe404 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_search.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_startapp_heart.xml b/app/src/main/res/drawable/ic_onboarding_startapp_heart.xml new file mode 100644 index 000000000..007c26c81 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_startapp_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_onboarding_startapp_yello.xml b/app/src/main/res/drawable/ic_onboarding_startapp_yello.xml new file mode 100644 index 000000000..809d65443 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_startapp_yello.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_studentid_down.xml b/app/src/main/res/drawable/ic_onboarding_studentid_down.xml new file mode 100644 index 000000000..53930fc7b --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_studentid_down.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_onboarding_x.xml b/app/src/main/res/drawable/ic_onboarding_x.xml new file mode 100644 index 000000000..a4ccc896b --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_x.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_yelloid_bubble.xml b/app/src/main/res/drawable/ic_onboarding_yelloid_bubble.xml new file mode 100644 index 000000000..5f7f0b6c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_yelloid_bubble.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_dialog_subs.xml b/app/src/main/res/drawable/ic_pay_dialog_subs.xml new file mode 100644 index 000000000..76b1d7625 --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_dialog_subs.xml @@ -0,0 +1,327 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_foot_second.xml b/app/src/main/res/drawable/ic_pay_foot_second.xml new file mode 100644 index 000000000..c668bed16 --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_foot_second.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pay_head_first.xml b/app/src/main/res/drawable/ic_pay_head_first.xml new file mode 100644 index 000000000..acea74ebc --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_head_first.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_head_second.xml b/app/src/main/res/drawable/ic_pay_head_second.xml new file mode 100644 index 000000000..48a7dc278 --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_head_second.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_head_third.xml b/app/src/main/res/drawable/ic_pay_head_third.xml new file mode 100644 index 000000000..dcedc1dc7 --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_head_third.xml @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_in_app_1.xml b/app/src/main/res/drawable/ic_pay_in_app_1.xml new file mode 100644 index 000000000..a09d10dd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_in_app_1.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_in_app_2.xml b/app/src/main/res/drawable/ic_pay_in_app_2.xml new file mode 100644 index 000000000..d1c9ce55d --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_in_app_2.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_in_app_5.xml b/app/src/main/res/drawable/ic_pay_in_app_5.xml new file mode 100644 index 000000000..03701afac --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_in_app_5.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pay_under_first.xml b/app/src/main/res/drawable/ic_pay_under_first.xml new file mode 100644 index 000000000..632311e4a --- /dev/null +++ b/app/src/main/res/drawable/ic_pay_under_first.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_point_zero.xml b/app/src/main/res/drawable/ic_point_zero.xml new file mode 100644 index 000000000..2c6c7e447 --- /dev/null +++ b/app/src/main/res/drawable/ic_point_zero.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_star.xml b/app/src/main/res/drawable/ic_profile_star.xml new file mode 100644 index 000000000..09a766624 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_star.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_subs.xml b/app/src/main/res/drawable/ic_profile_subs.xml new file mode 100644 index 000000000..07f10e9a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_subs.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_purple_star.xml b/app/src/main/res/drawable/ic_purple_star.xml new file mode 100644 index 000000000..897d8fd1c --- /dev/null +++ b/app/src/main/res/drawable/ic_purple_star.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quit.xml b/app/src/main/res/drawable/ic_quit.xml new file mode 100644 index 000000000..a77bff226 --- /dev/null +++ b/app/src/main/res/drawable/ic_quit.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quit_for_sure_1.xml b/app/src/main/res/drawable/ic_quit_for_sure_1.xml new file mode 100644 index 000000000..f55f3cc5e --- /dev/null +++ b/app/src/main/res/drawable/ic_quit_for_sure_1.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quit_for_sure_2.xml b/app/src/main/res/drawable/ic_quit_for_sure_2.xml new file mode 100644 index 000000000..9415ffd20 --- /dev/null +++ b/app/src/main/res/drawable/ic_quit_for_sure_2.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_quit_for_sure_3.xml b/app/src/main/res/drawable/ic_quit_for_sure_3.xml new file mode 100644 index 000000000..2cd3151ea --- /dev/null +++ b/app/src/main/res/drawable/ic_quit_for_sure_3.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_read_yello_point.xml b/app/src/main/res/drawable/ic_read_yello_point.xml new file mode 100644 index 000000000..54ad11f34 --- /dev/null +++ b/app/src/main/res/drawable/ic_read_yello_point.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_recommend_btn_pressed.xml b/app/src/main/res/drawable/ic_recommend_btn_pressed.xml new file mode 100644 index 000000000..0148c6bcf --- /dev/null +++ b/app/src/main/res/drawable/ic_recommend_btn_pressed.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_right.xml b/app/src/main/res/drawable/ic_right.xml new file mode 100644 index 000000000..3fe5f85b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_right.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_right_yellow.xml b/app/src/main/res/drawable/ic_right_yellow.xml new file mode 100644 index 000000000..700454d40 --- /dev/null +++ b/app/src/main/res/drawable/ic_right_yellow.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/ic_rotate.xml b/app/src/main/res/drawable/ic_rotate.xml new file mode 100644 index 000000000..a4859400b --- /dev/null +++ b/app/src/main/res/drawable/ic_rotate.xml @@ -0,0 +1,18 @@ + + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..836afc95c --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_shop.xml b/app/src/main/res/drawable/ic_shop.xml new file mode 100644 index 000000000..310a077f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_shop.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_shop_polygon.xml b/app/src/main/res/drawable/ic_shop_polygon.xml new file mode 100644 index 000000000..5d1da69e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_shop_polygon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_special.xml b/app/src/main/res/drawable/ic_special.xml new file mode 100644 index 000000000..f58830752 --- /dev/null +++ b/app/src/main/res/drawable/ic_special.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_splash_yello.xml b/app/src/main/res/drawable/ic_splash_yello.xml new file mode 100644 index 000000000..b3c94803a --- /dev/null +++ b/app/src/main/res/drawable/ic_splash_yello.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml new file mode 100644 index 000000000..3527a1760 --- /dev/null +++ b/app/src/main/res/drawable/ic_star.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_stroke_gradient_8dp.xml b/app/src/main/res/drawable/ic_stroke_gradient_8dp.xml new file mode 100644 index 000000000..188216d07 --- /dev/null +++ b/app/src/main/res/drawable/ic_stroke_gradient_8dp.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_student_highschool_face_default.xml b/app/src/main/res/drawable/ic_student_highschool_face_default.xml new file mode 100644 index 000000000..0b2b04581 --- /dev/null +++ b/app/src/main/res/drawable/ic_student_highschool_face_default.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_student_highschool_face_select.xml b/app/src/main/res/drawable/ic_student_highschool_face_select.xml new file mode 100644 index 000000000..2967a351f --- /dev/null +++ b/app/src/main/res/drawable/ic_student_highschool_face_select.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_student_highschool_face_unselected.xml b/app/src/main/res/drawable/ic_student_highschool_face_unselected.xml new file mode 100644 index 000000000..7b507197c --- /dev/null +++ b/app/src/main/res/drawable/ic_student_highschool_face_unselected.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_student_type_check_selected.xml b/app/src/main/res/drawable/ic_student_type_check_selected.xml new file mode 100644 index 000000000..5b2bf08e7 --- /dev/null +++ b/app/src/main/res/drawable/ic_student_type_check_selected.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_student_type_check_unselected.xml b/app/src/main/res/drawable/ic_student_type_check_unselected.xml new file mode 100644 index 000000000..25fba0e84 --- /dev/null +++ b/app/src/main/res/drawable/ic_student_type_check_unselected.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_student_university_face_default.xml b/app/src/main/res/drawable/ic_student_university_face_default.xml new file mode 100644 index 000000000..d62fd695e --- /dev/null +++ b/app/src/main/res/drawable/ic_student_university_face_default.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_student_university_face_select.xml b/app/src/main/res/drawable/ic_student_university_face_select.xml new file mode 100644 index 000000000..07c4d4f9a --- /dev/null +++ b/app/src/main/res/drawable/ic_student_university_face_select.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_student_university_face_unselected.xml b/app/src/main/res/drawable/ic_student_university_face_unselected.xml new file mode 100644 index 000000000..57d8c1c09 --- /dev/null +++ b/app/src/main/res/drawable/ic_student_university_face_unselected.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_tutorial_plus_point.xml b/app/src/main/res/drawable/ic_tutorial_plus_point.xml new file mode 100644 index 000000000..74bcc3dfa --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_plus_point.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tutorial_point.xml b/app/src/main/res/drawable/ic_tutorial_point.xml new file mode 100644 index 000000000..6136ea38a --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_point.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tutorial_point_p.xml b/app/src/main/res/drawable/ic_tutorial_point_p.xml new file mode 100644 index 000000000..348907693 --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_point_p.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_tutorial_yelloface.xml b/app/src/main/res/drawable/ic_tutorial_yelloface.xml new file mode 100644 index 000000000..904f29673 --- /dev/null +++ b/app/src/main/res/drawable/ic_tutorial_yelloface.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 000000000..5d63bc650 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_warning_mini.xml b/app/src/main/res/drawable/ic_warning_mini.xml new file mode 100644 index 000000000..c85c9ee8d --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_mini.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_yello_blue.xml b/app/src/main/res/drawable/ic_yello_blue.xml new file mode 100644 index 000000000..cef7b25ed --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_blue.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_yello_face.xml b/app/src/main/res/drawable/ic_yello_face.xml new file mode 100644 index 000000000..75dcb67c2 --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_face.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_yello_face_black.xml b/app/src/main/res/drawable/ic_yello_face_black.xml new file mode 100644 index 000000000..8155a871e --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_face_black.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_yello_launcher_background.xml b/app/src/main/res/drawable/ic_yello_launcher_background.xml new file mode 100644 index 000000000..ca3826a46 --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_yello_launcher_foreground.xml b/app/src/main/res/drawable/ic_yello_launcher_foreground.xml new file mode 100644 index 000000000..04bd4e15d --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_yello_pink.xml b/app/src/main/res/drawable/ic_yello_pink.xml new file mode 100644 index 000000000..ec180b426 --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_pink.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_yello_splash.xml b/app/src/main/res/drawable/ic_yello_splash.xml new file mode 100644 index 000000000..5e40bf4c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_splash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_yello_splash_group.xml b/app/src/main/res/drawable/ic_yello_splash_group.xml new file mode 100644 index 000000000..7be420311 --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_splash_group.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_yello_start_point.xml b/app/src/main/res/drawable/ic_yello_start_point.xml new file mode 100644 index 000000000..76362936e --- /dev/null +++ b/app/src/main/res/drawable/ic_yello_start_point.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/img_balloon_tail.xml b/app/src/main/res/drawable/img_balloon_tail.xml new file mode 100644 index 000000000..b43e29514 --- /dev/null +++ b/app/src/main/res/drawable/img_balloon_tail.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/img_banner_invite.xml b/app/src/main/res/drawable/img_banner_invite.xml new file mode 100644 index 000000000..9fa7f79c5 --- /dev/null +++ b/app/src/main/res/drawable/img_banner_invite.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_note_face1.xml b/app/src/main/res/drawable/img_note_face1.xml new file mode 100644 index 000000000..3c39d70a0 --- /dev/null +++ b/app/src/main/res/drawable/img_note_face1.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_note_face10.xml b/app/src/main/res/drawable/img_note_face10.xml new file mode 100644 index 000000000..936954caf --- /dev/null +++ b/app/src/main/res/drawable/img_note_face10.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_note_face2.xml b/app/src/main/res/drawable/img_note_face2.xml new file mode 100644 index 000000000..8f1544051 --- /dev/null +++ b/app/src/main/res/drawable/img_note_face2.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_note_face3.xml b/app/src/main/res/drawable/img_note_face3.xml new file mode 100644 index 000000000..e1dfea9dd --- /dev/null +++ b/app/src/main/res/drawable/img_note_face3.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_note_face4.xml b/app/src/main/res/drawable/img_note_face4.xml new file mode 100644 index 000000000..14b0b3c37 --- /dev/null +++ b/app/src/main/res/drawable/img_note_face4.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_note_face5.xml b/app/src/main/res/drawable/img_note_face5.xml new file mode 100644 index 000000000..e59b0c465 --- /dev/null +++ b/app/src/main/res/drawable/img_note_face5.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/img_note_face6.xml b/app/src/main/res/drawable/img_note_face6.xml new file mode 100644 index 000000000..f28476cb8 --- /dev/null +++ b/app/src/main/res/drawable/img_note_face6.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_note_face7.xml b/app/src/main/res/drawable/img_note_face7.xml new file mode 100644 index 000000000..ff4dba47e --- /dev/null +++ b/app/src/main/res/drawable/img_note_face7.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_note_face8.xml b/app/src/main/res/drawable/img_note_face8.xml new file mode 100644 index 000000000..65a00b1f0 --- /dev/null +++ b/app/src/main/res/drawable/img_note_face8.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/img_note_face9.xml b/app/src/main/res/drawable/img_note_face9.xml new file mode 100644 index 000000000..03ebb9c69 --- /dev/null +++ b/app/src/main/res/drawable/img_note_face9.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_notice_dummy.xml b/app/src/main/res/drawable/img_notice_dummy.xml new file mode 100644 index 000000000..f3bb638d0 --- /dev/null +++ b/app/src/main/res/drawable/img_notice_dummy.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_on_boarding_start.xml b/app/src/main/res/drawable/img_on_boarding_start.xml new file mode 100644 index 000000000..25ce43afd --- /dev/null +++ b/app/src/main/res/drawable/img_on_boarding_start.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_on_boarding_sync.xml b/app/src/main/res/drawable/img_on_boarding_sync.xml new file mode 100644 index 000000000..f7451347c --- /dev/null +++ b/app/src/main/res/drawable/img_on_boarding_sync.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_point.xml b/app/src/main/res/drawable/img_point.xml new file mode 100644 index 000000000..902fa082c --- /dev/null +++ b/app/src/main/res/drawable/img_point.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_point_welcome.xml b/app/src/main/res/drawable/img_point_welcome.xml new file mode 100644 index 000000000..020fce87c --- /dev/null +++ b/app/src/main/res/drawable/img_point_welcome.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_tutorial_a.xml b/app/src/main/res/drawable/img_tutorial_a.xml new file mode 100644 index 000000000..b4504f01f --- /dev/null +++ b/app/src/main/res/drawable/img_tutorial_a.xml @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_tutorial_b.xml b/app/src/main/res/drawable/img_tutorial_b.xml new file mode 100644 index 000000000..1b434e137 --- /dev/null +++ b/app/src/main/res/drawable/img_tutorial_b.xml @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_tutorial_c.xml b/app/src/main/res/drawable/img_tutorial_c.xml new file mode 100644 index 000000000..9ed6e1de2 --- /dev/null +++ b/app/src/main/res/drawable/img_tutorial_c.xml @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_tutorial_d.xml b/app/src/main/res/drawable/img_tutorial_d.xml new file mode 100644 index 000000000..4257550da --- /dev/null +++ b/app/src/main/res/drawable/img_tutorial_d.xml @@ -0,0 +1,310 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_tutorial_startapp.xml b/app/src/main/res/drawable/img_tutorial_startapp.xml new file mode 100644 index 000000000..86b79c1d5 --- /dev/null +++ b/app/src/main/res/drawable/img_tutorial_startapp.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_vote_no_friend.xml b/app/src/main/res/drawable/img_vote_no_friend.xml new file mode 100644 index 000000000..34c07a177 --- /dev/null +++ b/app/src/main/res/drawable/img_vote_no_friend.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_wait_balloon.xml b/app/src/main/res/drawable/img_wait_balloon.xml new file mode 100644 index 000000000..92cbbd571 --- /dev/null +++ b/app/src/main/res/drawable/img_wait_balloon.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/app/src/main/res/drawable/img_yello_basic.xml b/app/src/main/res/drawable/img_yello_basic.xml new file mode 100644 index 000000000..f353d598e --- /dev/null +++ b/app/src/main/res/drawable/img_yello_basic.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/img_yello_start_balloon_left.xml b/app/src/main/res/drawable/img_yello_start_balloon_left.xml new file mode 100644 index 000000000..f1b23e402 --- /dev/null +++ b/app/src/main/res/drawable/img_yello_start_balloon_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/img_yello_start_balloon_right.xml b/app/src/main/res/drawable/img_yello_start_balloon_right.xml new file mode 100644 index 000000000..0cceb05a8 --- /dev/null +++ b/app/src/main/res/drawable/img_yello_start_balloon_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/layerlist_onboarding_progressbar.xml b/app/src/main/res/drawable/layerlist_onboarding_progressbar.xml new file mode 100644 index 000000000..d25ed044c --- /dev/null +++ b/app/src/main/res/drawable/layerlist_onboarding_progressbar.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/layout_yello_progressbar.xml b/app/src/main/res/drawable/layout_yello_progressbar.xml new file mode 100644 index 000000000..41d871a0c --- /dev/null +++ b/app/src/main/res/drawable/layout_yello_progressbar.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_main_color_bnv_label.xml b/app/src/main/res/drawable/sel_main_color_bnv_label.xml new file mode 100644 index 000000000..6c126ef4e --- /dev/null +++ b/app/src/main/res/drawable/sel_main_color_bnv_label.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_main_icon_bnv_look.xml b/app/src/main/res/drawable/sel_main_icon_bnv_look.xml new file mode 100644 index 000000000..f02eae047 --- /dev/null +++ b/app/src/main/res/drawable/sel_main_icon_bnv_look.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_main_icon_bnv_my_yello.xml b/app/src/main/res/drawable/sel_main_icon_bnv_my_yello.xml new file mode 100644 index 000000000..95f97f800 --- /dev/null +++ b/app/src/main/res/drawable/sel_main_icon_bnv_my_yello.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_main_icon_bnv_profile.xml b/app/src/main/res/drawable/sel_main_icon_bnv_profile.xml new file mode 100644 index 000000000..f165dd89b --- /dev/null +++ b/app/src/main/res/drawable/sel_main_icon_bnv_profile.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_main_icon_bnv_recommend.xml b/app/src/main/res/drawable/sel_main_icon_bnv_recommend.xml new file mode 100644 index 000000000..8e92ce41f --- /dev/null +++ b/app/src/main/res/drawable/sel_main_icon_bnv_recommend.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_main_icon_bnv_yello.xml b/app/src/main/res/drawable/sel_main_icon_bnv_yello.xml new file mode 100644 index 000000000..ee9177abd --- /dev/null +++ b/app/src/main/res/drawable/sel_main_icon_bnv_yello.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_onbarding_editext_focus.xml b/app/src/main/res/drawable/sel_onbarding_editext_focus.xml new file mode 100644 index 000000000..78cf1a86b --- /dev/null +++ b/app/src/main/res/drawable/sel_onbarding_editext_focus.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_onboarding_friend_check.xml b/app/src/main/res/drawable/sel_onboarding_friend_check.xml new file mode 100644 index 000000000..d437ef22b --- /dev/null +++ b/app/src/main/res/drawable/sel_onboarding_friend_check.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_onboarding_nameid.xml b/app/src/main/res/drawable/sel_onboarding_nameid.xml new file mode 100644 index 000000000..e10002605 --- /dev/null +++ b/app/src/main/res/drawable/sel_onboarding_nameid.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_onboarding_studentid.xml b/app/src/main/res/drawable/sel_onboarding_studentid.xml new file mode 100644 index 000000000..42049343e --- /dev/null +++ b/app/src/main/res/drawable/sel_onboarding_studentid.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/sel_recommend_ic_list_btn.xml b/app/src/main/res/drawable/sel_recommend_ic_list_btn.xml new file mode 100644 index 000000000..80b317ea0 --- /dev/null +++ b/app/src/main/res/drawable/sel_recommend_ic_list_btn.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_32_rect.xml b/app/src/main/res/drawable/shape_black_fill_32_rect.xml new file mode 100644 index 000000000..ce4b27729 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_32_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_black_oval_yellow_shadow.xml b/app/src/main/res/drawable/shape_black_fill_black_oval_yellow_shadow.xml new file mode 100644 index 000000000..0ea6b3e5c --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_black_oval_yellow_shadow.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales200_line_8_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales200_line_8_rect.xml new file mode 100644 index 000000000..21407429c --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales200_line_8_rect.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales500_line_8_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales500_line_8_rect.xml new file mode 100644 index 000000000..31365df09 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales500_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales600_line_8_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales600_line_8_rect.xml new file mode 100644 index 000000000..e7a4d9531 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales600_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales700_line_100_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_100_rect.xml new file mode 100644 index 000000000..f643c57a1 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_100_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales700_line_8_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_8_rect.xml new file mode 100644 index 000000000..eb0e11c80 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales700_line_bot32_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_bot32_rect.xml new file mode 100644 index 000000000..57be02cd9 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_bot32_rect.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales700_line_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_rect.xml new file mode 100644 index 000000000..ddd68e967 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_rect.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_grayscales700_line_top32_rect.xml b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_top32_rect.xml new file mode 100644 index 000000000..66197114c --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_grayscales700_line_top32_rect.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_purple_line_10_rect.xml b/app/src/main/res/drawable/shape_black_fill_purple_line_10_rect.xml new file mode 100644 index 000000000..779321198 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_purple_line_10_rect.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_rect.xml b/app/src/main/res/drawable/shape_black_fill_rect.xml new file mode 100644 index 000000000..f014b24d3 --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_rect.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_black_fill_yello_main_500_line_8_rect.xml b/app/src/main/res/drawable/shape_black_fill_yello_main_500_line_8_rect.xml new file mode 100644 index 000000000..4fbe444ce --- /dev/null +++ b/app/src/main/res/drawable/shape_black_fill_yello_main_500_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_fab_circle.xml b/app/src/main/res/drawable/shape_fab_circle.xml new file mode 100644 index 000000000..15c0df104 --- /dev/null +++ b/app/src/main/res/drawable/shape_fab_circle.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/shape_female700_fill_female300_line_8_rect.xml b/app/src/main/res/drawable/shape_female700_fill_female300_line_8_rect.xml new file mode 100644 index 000000000..c1b1cf4c2 --- /dev/null +++ b/app/src/main/res/drawable/shape_female700_fill_female300_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_fill_black_32dp.xml b/app/src/main/res/drawable/shape_fill_black_32dp.xml new file mode 100644 index 000000000..0bc481598 --- /dev/null +++ b/app/src/main/res/drawable/shape_fill_black_32dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_fill_gray900_12dp.xml b/app/src/main/res/drawable/shape_fill_gray900_12dp.xml new file mode 100644 index 000000000..349162bca --- /dev/null +++ b/app/src/main/res/drawable/shape_fill_gray900_12dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_fill_red20_line_semantic_status_red500_rect_8.xml b/app/src/main/res/drawable/shape_fill_red20_line_semantic_status_red500_rect_8.xml new file mode 100644 index 000000000..10b298e8a --- /dev/null +++ b/app/src/main/res/drawable/shape_fill_red20_line_semantic_status_red500_rect_8.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_fill_yello500_100dp.xml b/app/src/main/res/drawable/shape_fill_yello500_100dp.xml new file mode 100644 index 000000000..3e022bd8d --- /dev/null +++ b/app/src/main/res/drawable/shape_fill_yello500_100dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_fill_yello500_8dp.xml b/app/src/main/res/drawable/shape_fill_yello500_8dp.xml new file mode 100644 index 000000000..51207c1d3 --- /dev/null +++ b/app/src/main/res/drawable/shape_fill_yello500_8dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_plus_fill_10_rect.xml b/app/src/main/res/drawable/shape_gradient_plus_fill_10_rect.xml new file mode 100644 index 000000000..8e0d7a525 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_plus_fill_10_rect.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_01.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_01.xml new file mode 100644 index 000000000..6ee305a88 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_01.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_02.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_02.xml new file mode 100644 index 000000000..4da6a1af4 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_02.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_03.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_03.xml new file mode 100644 index 000000000..aac59bff8 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_03.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_04.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_04.xml new file mode 100644 index 000000000..a42083edb --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_04.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_05.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_05.xml new file mode 100644 index 000000000..79bd80660 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_05.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_06.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_06.xml new file mode 100644 index 000000000..9cea446f6 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_06.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_07.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_07.xml new file mode 100644 index 000000000..b936a7131 --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_07.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_08.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_08.xml new file mode 100644 index 000000000..f915ef4ca --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_08.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_09.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_09.xml new file mode 100644 index 000000000..69740628a --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_09.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_10.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_10.xml new file mode 100644 index 000000000..4509469db --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_10.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_11.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_11.xml new file mode 100644 index 000000000..aac0e2efc --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_11.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gradient_vote_bg_12.xml b/app/src/main/res/drawable/shape_gradient_vote_bg_12.xml new file mode 100644 index 000000000..bae81eeab --- /dev/null +++ b/app/src/main/res/drawable/shape_gradient_vote_bg_12.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gray900_fill_18_rect.xml b/app/src/main/res/drawable/shape_gray900_fill_18_rect.xml new file mode 100644 index 000000000..3d5f9ca3f --- /dev/null +++ b/app/src/main/res/drawable/shape_gray900_fill_18_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_gray90_fill_8_rect.xml b/app/src/main/res/drawable/shape_gray90_fill_8_rect.xml new file mode 100644 index 000000000..543686873 --- /dev/null +++ b/app/src/main/res/drawable/shape_gray90_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_graypoint_fill_8_rect.xml b/app/src/main/res/drawable/shape_graypoint_fill_8_rect.xml new file mode 100644 index 000000000..d6f326ebe --- /dev/null +++ b/app/src/main/res/drawable/shape_graypoint_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales50_fill_8_rect.xml b/app/src/main/res/drawable/shape_grayscales50_fill_8_rect.xml new file mode 100644 index 000000000..2660f70ab --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales50_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/shape_grayscales700_line_8_rect.xml b/app/src/main/res/drawable/shape_grayscales700_line_8_rect.xml new file mode 100644 index 000000000..cc9bcdf7f --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales700_line_8_rect.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales800_fill_100_rect.xml b/app/src/main/res/drawable/shape_grayscales800_fill_100_rect.xml new file mode 100644 index 000000000..df4aab4e8 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales800_fill_100_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales800_fill_8_rect.xml b/app/src/main/res/drawable/shape_grayscales800_fill_8_rect.xml new file mode 100644 index 000000000..48436ecf1 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales800_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales800_fill_grayscales700_dashline_4_rect.xml b/app/src/main/res/drawable/shape_grayscales800_fill_grayscales700_dashline_4_rect.xml new file mode 100644 index 000000000..fec2740c8 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales800_fill_grayscales700_dashline_4_rect.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales800_fill_grayscales700_line_8_rect.xml b/app/src/main/res/drawable/shape_grayscales800_fill_grayscales700_line_8_rect.xml new file mode 100644 index 000000000..56be9212e --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales800_fill_grayscales700_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_100_rect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_100_rect.xml new file mode 100644 index 000000000..62e43a9de --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_100_rect.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_12_rect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_12_rect.xml new file mode 100644 index 000000000..1d19862fe --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_12_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_8_rect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_8_rect.xml new file mode 100644 index 000000000..599276d73 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_grayscales600_line_8_rect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales600_line_8_rect.xml new file mode 100644 index 000000000..f405847d5 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales600_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_circle.xml b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_circle.xml new file mode 100644 index 000000000..132244d0f --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_circle.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_dashline_8_rect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_dashline_8_rect.xml new file mode 100644 index 000000000..6f9b0d785 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_dashline_8_rect.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_leftrect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_leftrect.xml new file mode 100644 index 000000000..9c736227d --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_leftrect.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_rightrect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_rightrect.xml new file mode 100644 index 000000000..347924d70 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_rightrect.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_square.xml b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_square.xml new file mode 100644 index 000000000..4dad052c7 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_grayscales700_line_8_square.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_top10_rect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_top10_rect.xml new file mode 100644 index 000000000..877a17e92 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_top10_rect.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_leftrect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_leftrect.xml new file mode 100644 index 000000000..e8713323d --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_leftrect.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_rightrect.xml b/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_rightrect.xml new file mode 100644 index 000000000..5314fe405 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_rightrect.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_square.xml b/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_square.xml new file mode 100644 index 000000000..4ca30503f --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales900_fill_yello_main600_line_8_square.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/shape_grayscales_800_fill_100_rect.xml b/app/src/main/res/drawable/shape_grayscales_800_fill_100_rect.xml new file mode 100644 index 000000000..1fd1b7893 --- /dev/null +++ b/app/src/main/res/drawable/shape_grayscales_800_fill_100_rect.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/shape_kakao_100_rect.xml b/app/src/main/res/drawable/shape_kakao_100_rect.xml new file mode 100644 index 000000000..fa29a3ada --- /dev/null +++ b/app/src/main/res/drawable/shape_kakao_100_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_kakao_yellow_fill_8_rect.xml b/app/src/main/res/drawable/shape_kakao_yellow_fill_8_rect.xml new file mode 100644 index 000000000..fe492bfae --- /dev/null +++ b/app/src/main/res/drawable/shape_kakao_yellow_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_male700_fill_male300_line_8_rect.xml b/app/src/main/res/drawable/shape_male700_fill_male300_line_8_rect.xml new file mode 100644 index 000000000..fc84b93dd --- /dev/null +++ b/app/src/main/res/drawable/shape_male700_fill_male300_line_8_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_purple_fill_10_left_top_right_bot.xml b/app/src/main/res/drawable/shape_purple_fill_10_left_top_right_bot.xml new file mode 100644 index 000000000..2759181e3 --- /dev/null +++ b/app/src/main/res/drawable/shape_purple_fill_10_left_top_right_bot.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_purple_gradient_100dp.xml b/app/src/main/res/drawable/shape_purple_gradient_100dp.xml new file mode 100644 index 000000000..3c6046759 --- /dev/null +++ b/app/src/main/res/drawable/shape_purple_gradient_100dp.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_purple_gradient_30dp.xml b/app/src/main/res/drawable/shape_purple_gradient_30dp.xml new file mode 100644 index 000000000..336171bd3 --- /dev/null +++ b/app/src/main/res/drawable/shape_purple_gradient_30dp.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_purple_gradient_8dp.xml b/app/src/main/res/drawable/shape_purple_gradient_8dp.xml new file mode 100644 index 000000000..d0a026bf1 --- /dev/null +++ b/app/src/main/res/drawable/shape_purple_gradient_8dp.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_purple_sub500_fill_4_rect.xml b/app/src/main/res/drawable/shape_purple_sub500_fill_4_rect.xml new file mode 100644 index 000000000..beb2dc01c --- /dev/null +++ b/app/src/main/res/drawable/shape_purple_sub500_fill_4_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_rect.xml b/app/src/main/res/drawable/shape_rect.xml new file mode 100644 index 000000000..44d1b2670 --- /dev/null +++ b/app/src/main/res/drawable/shape_rect.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_red_fill_red500_line_10_rect.xml b/app/src/main/res/drawable/shape_red_fill_red500_line_10_rect.xml new file mode 100644 index 000000000..b197139a5 --- /dev/null +++ b/app/src/main/res/drawable/shape_red_fill_red500_line_10_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_rounded_dialog.xml b/app/src/main/res/drawable/shape_rounded_dialog.xml new file mode 100644 index 000000000..f18e96b99 --- /dev/null +++ b/app/src/main/res/drawable/shape_rounded_dialog.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_shimmer_grayscale600_circle.xml b/app/src/main/res/drawable/shape_shimmer_grayscale600_circle.xml new file mode 100644 index 000000000..2a55fd4fa --- /dev/null +++ b/app/src/main/res/drawable/shape_shimmer_grayscale600_circle.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/shape_shimmer_grayscale700_fill_2_rect.xml b/app/src/main/res/drawable/shape_shimmer_grayscale700_fill_2_rect.xml new file mode 100644 index 000000000..818c37383 --- /dev/null +++ b/app/src/main/res/drawable/shape_shimmer_grayscale700_fill_2_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_shimmer_greyscale800_circle.xml b/app/src/main/res/drawable/shape_shimmer_greyscale800_circle.xml new file mode 100644 index 000000000..d609eeeea --- /dev/null +++ b/app/src/main/res/drawable/shape_shimmer_greyscale800_circle.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/shape_shimmer_greyscale800_fill_2_rect.xml b/app/src/main/res/drawable/shape_shimmer_greyscale800_fill_2_rect.xml new file mode 100644 index 000000000..253596fba --- /dev/null +++ b/app/src/main/res/drawable/shape_shimmer_greyscale800_fill_2_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_shimmer_greyscale_off_fill_2_rect.xml b/app/src/main/res/drawable/shape_shimmer_greyscale_off_fill_2_rect.xml new file mode 100644 index 000000000..635003ea7 --- /dev/null +++ b/app/src/main/res/drawable/shape_shimmer_greyscale_off_fill_2_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_unselected_tab_indicator.xml b/app/src/main/res/drawable/shape_unselected_tab_indicator.xml new file mode 100644 index 000000000..0ba91565e --- /dev/null +++ b/app/src/main/res/drawable/shape_unselected_tab_indicator.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white35_fill_100_rect.xml b/app/src/main/res/drawable/shape_white35_fill_100_rect.xml new file mode 100644 index 000000000..a7510bdfc --- /dev/null +++ b/app/src/main/res/drawable/shape_white35_fill_100_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white35_fill_20_rect.xml b/app/src/main/res/drawable/shape_white35_fill_20_rect.xml new file mode 100644 index 000000000..ca346d1cc --- /dev/null +++ b/app/src/main/res/drawable/shape_white35_fill_20_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white35_oval.xml b/app/src/main/res/drawable/shape_white35_oval.xml new file mode 100644 index 000000000..f85c2c115 --- /dev/null +++ b/app/src/main/res/drawable/shape_white35_oval.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white59_fill_100_rect.xml b/app/src/main/res/drawable/shape_white59_fill_100_rect.xml new file mode 100644 index 000000000..62f03e4fd --- /dev/null +++ b/app/src/main/res/drawable/shape_white59_fill_100_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white_35per_100dp.xml b/app/src/main/res/drawable/shape_white_35per_100dp.xml new file mode 100644 index 000000000..534952e78 --- /dev/null +++ b/app/src/main/res/drawable/shape_white_35per_100dp.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white_fill_10_rect.xml b/app/src/main/res/drawable/shape_white_fill_10_rect.xml new file mode 100644 index 000000000..effbe27c3 --- /dev/null +++ b/app/src/main/res/drawable/shape_white_fill_10_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white_fill_8_rect.xml b/app/src/main/res/drawable/shape_white_fill_8_rect.xml new file mode 100644 index 000000000..68e8a515b --- /dev/null +++ b/app/src/main/res/drawable/shape_white_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_white_fill_grayscales500_line_rect.xml b/app/src/main/res/drawable/shape_white_fill_grayscales500_line_rect.xml new file mode 100644 index 000000000..b332efa29 --- /dev/null +++ b/app/src/main/res/drawable/shape_white_fill_grayscales500_line_rect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/shape_yello500_fill_500_rect.xml b/app/src/main/res/drawable/shape_yello500_fill_500_rect.xml new file mode 100644 index 000000000..fefe68606 --- /dev/null +++ b/app/src/main/res/drawable/shape_yello500_fill_500_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_yello500_fill_8_rect.xml b/app/src/main/res/drawable/shape_yello500_fill_8_rect.xml new file mode 100644 index 000000000..045178e4e --- /dev/null +++ b/app/src/main/res/drawable/shape_yello500_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_yello500_line_100_rect.xml b/app/src/main/res/drawable/shape_yello500_line_100_rect.xml new file mode 100644 index 000000000..bb0fc7885 --- /dev/null +++ b/app/src/main/res/drawable/shape_yello500_line_100_rect.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/shape_yello_main_500_fill_100_rect.xml b/app/src/main/res/drawable/shape_yello_main_500_fill_100_rect.xml new file mode 100644 index 000000000..5bc8408b1 --- /dev/null +++ b/app/src/main/res/drawable/shape_yello_main_500_fill_100_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_yello_main_500_fill_500_botshadow_rect.xml b/app/src/main/res/drawable/shape_yello_main_500_fill_500_botshadow_rect.xml new file mode 100644 index 000000000..96f71e9ab --- /dev/null +++ b/app/src/main/res/drawable/shape_yello_main_500_fill_500_botshadow_rect.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/shape_yello_main_500_fill_8_rect.xml b/app/src/main/res/drawable/shape_yello_main_500_fill_8_rect.xml new file mode 100644 index 000000000..045178e4e --- /dev/null +++ b/app/src/main/res/drawable/shape_yello_main_500_fill_8_rect.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_yello_main_500_fill_black_line_rect.xml b/app/src/main/res/drawable/shape_yello_main_500_fill_black_line_rect.xml new file mode 100644 index 000000000..16217c6d4 --- /dev/null +++ b/app/src/main/res/drawable/shape_yello_main_500_fill_black_line_rect.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_fill_gray700.xml b/app/src/main/res/drawable/vector_fill_gray700.xml new file mode 100644 index 000000000..3d50e521b --- /dev/null +++ b/app/src/main/res/drawable/vector_fill_gray700.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/vector_fill_white.xml b/app/src/main/res/drawable/vector_fill_white.xml new file mode 100644 index 000000000..78133ce44 --- /dev/null +++ b/app/src/main/res/drawable/vector_fill_white.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/font_pretendard_bold.xml b/app/src/main/res/font/font_pretendard_bold.xml new file mode 100644 index 000000000..d2d352de8 --- /dev/null +++ b/app/src/main/res/font/font_pretendard_bold.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/font_pretendard_medium.xml b/app/src/main/res/font/font_pretendard_medium.xml new file mode 100644 index 000000000..c995547bf --- /dev/null +++ b/app/src/main/res/font/font_pretendard_medium.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/font_pretendard_regular.xml b/app/src/main/res/font/font_pretendard_regular.xml new file mode 100644 index 000000000..9ffa0eb93 --- /dev/null +++ b/app/src/main/res/font/font_pretendard_regular.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/font_pretendard_semibold.xml b/app/src/main/res/font/font_pretendard_semibold.xml new file mode 100644 index 000000000..7b5200824 --- /dev/null +++ b/app/src/main/res/font/font_pretendard_semibold.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/pretendard_bold.otf b/app/src/main/res/font/pretendard_bold.otf new file mode 100644 index 000000000..a52ef3991 Binary files /dev/null and b/app/src/main/res/font/pretendard_bold.otf differ diff --git a/app/src/main/res/font/pretendard_medium.otf b/app/src/main/res/font/pretendard_medium.otf new file mode 100644 index 000000000..a2dc009f6 Binary files /dev/null and b/app/src/main/res/font/pretendard_medium.otf differ diff --git a/app/src/main/res/font/pretendard_regular.otf b/app/src/main/res/font/pretendard_regular.otf new file mode 100644 index 000000000..c940185ae Binary files /dev/null and b/app/src/main/res/font/pretendard_regular.otf differ diff --git a/app/src/main/res/font/pretendard_semibold.otf b/app/src/main/res/font/pretendard_semibold.otf new file mode 100644 index 000000000..c375b545d Binary files /dev/null and b/app/src/main/res/font/pretendard_semibold.otf differ diff --git a/app/src/main/res/font/unbounded_black.ttf b/app/src/main/res/font/unbounded_black.ttf new file mode 100644 index 000000000..bcbdf99b8 Binary files /dev/null and b/app/src/main/res/font/unbounded_black.ttf differ diff --git a/app/src/main/res/font/unbounded_bold.ttf b/app/src/main/res/font/unbounded_bold.ttf new file mode 100644 index 000000000..dfa10093a Binary files /dev/null and b/app/src/main/res/font/unbounded_bold.ttf differ diff --git a/app/src/main/res/font/unbounded_extrabold.ttf b/app/src/main/res/font/unbounded_extrabold.ttf new file mode 100644 index 000000000..af8f13b50 Binary files /dev/null and b/app/src/main/res/font/unbounded_extrabold.ttf differ diff --git a/app/src/main/res/font/unbounded_extralight.ttf b/app/src/main/res/font/unbounded_extralight.ttf new file mode 100644 index 000000000..b72881c6a Binary files /dev/null and b/app/src/main/res/font/unbounded_extralight.ttf differ diff --git a/app/src/main/res/font/unbounded_light.ttf b/app/src/main/res/font/unbounded_light.ttf new file mode 100644 index 000000000..2fe11e339 Binary files /dev/null and b/app/src/main/res/font/unbounded_light.ttf differ diff --git a/app/src/main/res/font/unbounded_medium.ttf b/app/src/main/res/font/unbounded_medium.ttf new file mode 100644 index 000000000..b59d89e66 Binary files /dev/null and b/app/src/main/res/font/unbounded_medium.ttf differ diff --git a/app/src/main/res/font/unbounded_regular.ttf b/app/src/main/res/font/unbounded_regular.ttf new file mode 100644 index 000000000..e54e3df91 Binary files /dev/null and b/app/src/main/res/font/unbounded_regular.ttf differ diff --git a/app/src/main/res/font/unbounded_semibold.ttf b/app/src/main/res/font/unbounded_semibold.ttf new file mode 100644 index 000000000..31132bc01 Binary files /dev/null and b/app/src/main/res/font/unbounded_semibold.ttf differ diff --git a/app/src/main/res/layout/activity_get_alarm.xml b/app/src/main/res/layout/activity_get_alarm.xml new file mode 100644 index 000000000..697797ac7 --- /dev/null +++ b/app/src/main/res/layout/activity_get_alarm.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..31ecf8f79 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_my_yello_read.xml b/app/src/main/res/layout/activity_my_yello_read.xml new file mode 100644 index 000000000..6029a3cc7 --- /dev/null +++ b/app/src/main/res/layout/activity_my_yello_read.xml @@ -0,0 +1,559 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_name_edit.xml b/app/src/main/res/layout/activity_name_edit.xml new file mode 100644 index 000000000..7bdeabac8 --- /dev/null +++ b/app/src/main/res/layout/activity_name_edit.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml new file mode 100644 index 000000000..818bcf57b --- /dev/null +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_pay.xml b/app/src/main/res/layout/activity_pay.xml new file mode 100644 index 000000000..bf4e102b7 --- /dev/null +++ b/app/src/main/res/layout/activity_pay.xml @@ -0,0 +1,586 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile_manage.xml b/app/src/main/res/layout/activity_profile_manage.xml new file mode 100644 index 000000000..25a0c6e9f --- /dev/null +++ b/app/src/main/res/layout/activity_profile_manage.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile_quit_one.xml b/app/src/main/res/layout/activity_profile_quit_one.xml new file mode 100644 index 000000000..a348c5007 --- /dev/null +++ b/app/src/main/res/layout/activity_profile_quit_one.xml @@ -0,0 +1,301 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile_quit_two.xml b/app/src/main/res/layout/activity_profile_quit_two.xml new file mode 100644 index 000000000..523b23dd9 --- /dev/null +++ b/app/src/main/res/layout/activity_profile_quit_two.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile_school_detail.xml b/app/src/main/res/layout/activity_profile_school_detail.xml new file mode 100644 index 000000000..b5085bef0 --- /dev/null +++ b/app/src/main/res/layout/activity_profile_school_detail.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile_school_mod.xml b/app/src/main/res/layout/activity_profile_school_mod.xml new file mode 100644 index 000000000..b5085bef0 --- /dev/null +++ b/app/src/main/res/layout/activity_profile_school_mod.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile_univ_detail.xml b/app/src/main/res/layout/activity_profile_univ_detail.xml new file mode 100644 index 000000000..a57f7bfc7 --- /dev/null +++ b/app/src/main/res/layout/activity_profile_univ_detail.xml @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_profile_univ_mod.xml b/app/src/main/res/layout/activity_profile_univ_mod.xml new file mode 100644 index 000000000..b35185614 --- /dev/null +++ b/app/src/main/res/layout/activity_profile_univ_mod.xml @@ -0,0 +1,323 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml new file mode 100644 index 000000000..502781cf6 --- /dev/null +++ b/app/src/main/res/layout/activity_search.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_sign_in.xml b/app/src/main/res/layout/activity_sign_in.xml new file mode 100644 index 000000000..92b6c3436 --- /dev/null +++ b/app/src/main/res/layout/activity_sign_in.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_social_sync.xml b/app/src/main/res/layout/activity_social_sync.xml new file mode 100644 index 000000000..62fe01519 --- /dev/null +++ b/app/src/main/res/layout/activity_social_sync.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 000000000..07f26a3c4 --- /dev/null +++ b/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_tutorial_a.xml b/app/src/main/res/layout/activity_tutorial_a.xml new file mode 100644 index 000000000..c422ee02f --- /dev/null +++ b/app/src/main/res/layout/activity_tutorial_a.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_tutorial_b.xml b/app/src/main/res/layout/activity_tutorial_b.xml new file mode 100644 index 000000000..a820224b0 --- /dev/null +++ b/app/src/main/res/layout/activity_tutorial_b.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_tutorial_c.xml b/app/src/main/res/layout/activity_tutorial_c.xml new file mode 100644 index 000000000..e0ce2e858 --- /dev/null +++ b/app/src/main/res/layout/activity_tutorial_c.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_tutorial_d.xml b/app/src/main/res/layout/activity_tutorial_d.xml new file mode 100644 index 000000000..3b30211e2 --- /dev/null +++ b/app/src/main/res/layout/activity_tutorial_d.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_tutorial_end_pluspoint.xml b/app/src/main/res/layout/activity_tutorial_end_pluspoint.xml new file mode 100644 index 000000000..6a1b75be3 --- /dev/null +++ b/app/src/main/res/layout/activity_tutorial_end_pluspoint.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_tutorial_end_point.xml b/app/src/main/res/layout/activity_tutorial_end_point.xml new file mode 100644 index 000000000..47e84dfb9 --- /dev/null +++ b/app/src/main/res/layout/activity_tutorial_end_point.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_vote.xml b/app/src/main/res/layout/activity_vote.xml new file mode 100644 index 000000000..5ae5f5177 --- /dev/null +++ b/app/src/main/res/layout/activity_vote.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_point_after.xml b/app/src/main/res/layout/dialog_point_after.xml new file mode 100644 index 000000000..925d7fd13 --- /dev/null +++ b/app/src/main/res/layout/dialog_point_after.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_point_use.xml b/app/src/main/res/layout/dialog_point_use.xml new file mode 100644 index 000000000..402ae7afe --- /dev/null +++ b/app/src/main/res/layout/dialog_point_use.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_reading_ticket_after.xml b/app/src/main/res/layout/dialog_reading_ticket_after.xml new file mode 100644 index 000000000..e99be1b26 --- /dev/null +++ b/app/src/main/res/layout/dialog_reading_ticket_after.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_reading_ticket_use.xml b/app/src/main/res/layout/dialog_reading_ticket_use.xml new file mode 100644 index 000000000..c172880f1 --- /dev/null +++ b/app/src/main/res/layout/dialog_reading_ticket_use.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_add_friend.xml b/app/src/main/res/layout/fragment_add_friend.xml new file mode 100644 index 000000000..38f33752e --- /dev/null +++ b/app/src/main/res/layout/fragment_add_friend.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_code.xml b/app/src/main/res/layout/fragment_code.xml new file mode 100644 index 000000000..c16264c3a --- /dev/null +++ b/app/src/main/res/layout/fragment_code.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_check_name.xml b/app/src/main/res/layout/fragment_dialog_check_name.xml new file mode 100644 index 000000000..6d3ad68c6 --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_check_name.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_department.xml b/app/src/main/res/layout/fragment_dialog_department.xml new file mode 100644 index 000000000..c625b2025 --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_department.xml @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_group.xml b/app/src/main/res/layout/fragment_dialog_group.xml new file mode 100644 index 000000000..27b5116b2 --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_group.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_highschool.xml b/app/src/main/res/layout/fragment_dialog_highschool.xml new file mode 100644 index 000000000..9090e54c4 --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_highschool.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_student_id.xml b/app/src/main/res/layout/fragment_dialog_student_id.xml new file mode 100644 index 000000000..c433b5b2f --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_student_id.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_dialog_university.xml b/app/src/main/res/layout/fragment_dialog_university.xml new file mode 100644 index 000000000..a3741a35e --- /dev/null +++ b/app/src/main/res/layout/fragment_dialog_university.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_highschool.xml b/app/src/main/res/layout/fragment_highschool.xml new file mode 100644 index 000000000..59b9cc06d --- /dev/null +++ b/app/src/main/res/layout/fragment_highschool.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_invite_friend_dialog.xml b/app/src/main/res/layout/fragment_invite_friend_dialog.xml new file mode 100644 index 000000000..95fd83f4b --- /dev/null +++ b/app/src/main/res/layout/fragment_invite_friend_dialog.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_look.xml b/app/src/main/res/layout/fragment_look.xml new file mode 100644 index 000000000..cc1659e19 --- /dev/null +++ b/app/src/main/res/layout/fragment_look.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_my_yello.xml b/app/src/main/res/layout/fragment_my_yello.xml new file mode 100644 index 000000000..def6f4fa6 --- /dev/null +++ b/app/src/main/res/layout/fragment_my_yello.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_note.xml b/app/src/main/res/layout/fragment_note.xml new file mode 100644 index 000000000..e804d8489 --- /dev/null +++ b/app/src/main/res/layout/fragment_note.xml @@ -0,0 +1,692 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_note_frame.xml b/app/src/main/res/layout/fragment_note_frame.xml new file mode 100644 index 000000000..877762e7c --- /dev/null +++ b/app/src/main/res/layout/fragment_note_frame.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_notice_dialog.xml b/app/src/main/res/layout/fragment_notice_dialog.xml new file mode 100644 index 000000000..b52c5bdd8 --- /dev/null +++ b/app/src/main/res/layout/fragment_notice_dialog.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_notice_resubscribe.xml b/app/src/main/res/layout/fragment_notice_resubscribe.xml new file mode 100644 index 000000000..76381510c --- /dev/null +++ b/app/src/main/res/layout/fragment_notice_resubscribe.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_pay_in_app_dialog.xml b/app/src/main/res/layout/fragment_pay_in_app_dialog.xml new file mode 100644 index 000000000..4ec81bf78 --- /dev/null +++ b/app/src/main/res/layout/fragment_pay_in_app_dialog.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_pay_subs_dialog.xml b/app/src/main/res/layout/fragment_pay_subs_dialog.xml new file mode 100644 index 000000000..0b52e6858 --- /dev/null +++ b/app/src/main/res/layout/fragment_pay_subs_dialog.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_point.xml b/app/src/main/res/layout/fragment_point.xml new file mode 100644 index 000000000..1516594b4 --- /dev/null +++ b/app/src/main/res/layout/fragment_point.xml @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml new file mode 100644 index 000000000..715f57660 --- /dev/null +++ b/app/src/main/res/layout/fragment_profile.xml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_profile_delete_bottom_sheet.xml b/app/src/main/res/layout/fragment_profile_delete_bottom_sheet.xml new file mode 100644 index 000000000..218d20aae --- /dev/null +++ b/app/src/main/res/layout/fragment_profile_delete_bottom_sheet.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_profile_item_bottom_sheet.xml b/app/src/main/res/layout/fragment_profile_item_bottom_sheet.xml new file mode 100644 index 000000000..759175da3 --- /dev/null +++ b/app/src/main/res/layout/fragment_profile_item_bottom_sheet.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +