diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..811298361 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @boostcampwm-2022/beep \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..6ddb6aae2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +resolved: #number + +## 작업 내용 + +> 어떤 작업을 했는지 적어주세요! + +## 체크리스트 +- [ ] Assignees 설정 +- [ ] Labels 설정 +- [ ] Projects 설정 +- [ ] Milestone 설정 + +## 동작 화면 + +> 존재하지 않는다면 패스해주세요! + +## 버그 + +> 존재하지 않는다면 패스해주세요! \ No newline at end of file diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..fcfafc37c --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,22 @@ +test: + - domain/src/test/**/* + - presentation/src/test/**/* + - presentation/src/androidTest/**/* + - data/src/test/**/* + +chore: + - app/*.gradle + - build.gradle + - buildSrc/**/* + +style: + - presentation/src/main/res/**/* + +feat: + - app/**/* + - data/src/**/* + - domain/src/**/* + - presentation/src/main/java/**/* + +docs: + - README.md diff --git a/.github/workflows/beepCD.yml b/.github/workflows/beepCD.yml new file mode 100644 index 000000000..bc115971b --- /dev/null +++ b/.github/workflows/beepCD.yml @@ -0,0 +1,38 @@ +name: Android CD +on: + pull_request: + branches: [ main ] + +jobs: + build: + name: Beep CD + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + + - name: set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create google-service + run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./presentation/google-services.json + + - name: Create Local Properties + run: echo '${{ secrets.LOCAL_PROPERTIES }}' > ./local.properties + + - name: build release + run: ./gradlew assembleRelease + + - name: upload artifact to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + + with: + appId: ${{secrets.FIREBASE_APP_ID}} + serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }} + groups: Developer + file: app/build/outputs/apk/release/app-release-unsigned.apk diff --git a/.github/workflows/beepCI.yml b/.github/workflows/beepCI.yml new file mode 100644 index 000000000..18c9b6dbe --- /dev/null +++ b/.github/workflows/beepCI.yml @@ -0,0 +1,38 @@ +name: Android CI +on: + pull_request: + branches: [ develop ] + +jobs: + build: + name: Beep CI + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup JDK 11 + uses: actions/setup-java@v3 + with: + distribution: "zulu" + java-version: 11 + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create google-service + run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./presentation/google-services.json + + - name: Create Local Properties + run: echo '${{ secrets.LOCAL_PROPERTIES }}' > ./local.properties + + - name: Build with Gradle + run: ./gradlew build + + - name: Run unit tests + run: ./gradlew testDebugUnitTest diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..4c92f5bdf --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,14 @@ +name: "Pull Request Labeler" +on: + pull_request_target: + branches: + - develop + +jobs: + label: + runs-on: ubuntu-latest + + steps: + - uses: actions/labeler@v2 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/reviewKtlint.yml b/.github/workflows/reviewKtlint.yml new file mode 100644 index 000000000..1cac149a7 --- /dev/null +++ b/.github/workflows/reviewKtlint.yml @@ -0,0 +1,25 @@ +name: ktlint + +on: + pull_request: + branches: + - main + - develop +jobs: + ktlint: + name: Check Code Quality + runs-on: ubuntu-latest + + steps: + - name: Clone repo + uses: actions/checkout@master + with: + fetch-depth: 1 + - name: ktlint + uses: ScaCap/action-ktlint@master + with: + github_token: ${{ secrets.github_token }} + reporter: github-pr-review + android: true + fail_on_error: true + level: warning diff --git a/README.md b/README.md index 29e333f5f..4d2042ff1 100644 --- a/README.md +++ b/README.md @@ -1 +1,75 @@ -# android04-BEEP \ No newline at end of file +

+ 기프티콘, 잊지 말고 삡! 하세요 👾 +

+ +

+ +![Beep_Readme_Header](https://user-images.githubusercontent.com/53300830/204736853-81cc432a-cd29-461f-bc2f-e52bbdef97fb.png) + +

+ +

+ + + + +

+

+ +## 프로젝트 + +### 소개 + +- 다양한 기프티콘을 한 곳에 모아서 관리해보세요 +- 기프티콘을 단순히 저장하고 보여주는 것만이 아닌 본인 위치를 기반으로 기프티콘을 추천해드려요 +- 보안이 걱정이 된다면! 지문인증을 활용해서 기프티콘의 바코드를 숨겨보세요 + +### 조원 소개 + +|[김명석](https://github.com/audxo112)|[박명범](https://github.com/mangbaam)|[양수진](https://github.com/yangsooplus)|[이지훈](https://github.com/lee-ji-hoon)| +|------|---|---|------| +||||| + +## 기능 소개 + +### 홈 및 보안설정 + +|스플래시|보안 설정|홈| +|:-----:|:-----:|:-----:| +|||| + +### 지도 + +|사용 가능한 브랜드|마커 갱신| +|:------:|:-----:| +| || + +### 목록 + +|목록 정렬|브랜드검색|삭제 및 사용처리| +|:-----:|:-----:|:-----:| +|||| + +### 기프티콘 사용 및 사용기록 + +|일반 기프티콘 사용|금액권 사용|사용 기록| +|:-----:|:-----:|:-----:| +|||| + +### 기프티콘 추가 + +|자동파싱|직접파싱| +|:-----:|:-----:| +||| + +### 위젯 및 알림 + +|위젯|알림| +|:-----:|:-----:| +||| + +### 설정, 기프티콘 확인 + +|설정|사용한 기프티콘| +|:-----:|:-----:| +||| diff --git a/app/.gitignore b/app/.gitignore index da214ccbe..6c1a5a3af 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -73,9 +73,6 @@ captures/ .externalNativeBuild .cxx/ -# Google Services (e.g. APIs or Firebase) -google-services.json - # Version control vcs.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5b1be414..ac3931360 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,11 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("dagger.hilt.android.plugin") + id("com.google.android.gms.oss-licenses-plugin") } android { @@ -16,6 +21,9 @@ android { versionName = AppConfig.versionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "kakaoSearchId", getApiKey("kakao_search_id")) + manifestPlaceholders["naver_map_api_id"] = getApiKey("naver_map_api_id") } buildTypes { @@ -26,12 +34,19 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = AppConfig.jvmTarget + } + buildFeatures { + dataBinding = true + } + + packagingOptions { + resources.excludes.add("META-INF/LICENSE*") } } @@ -39,4 +54,12 @@ dependencies { implementation(project(":domain")) implementation(project(":presentation")) implementation(project(":data")) + implementation(platform(Libraries.FIREBASE_BOM)) + kapt(Kapt.APP_LIBRARIES) + implementation(Libraries.APP_LIBRARIES) + annotationProcessor(AnnotationProcessors.APP_LIBRARIES) +} + +fun getApiKey(propertyKey: String): String { + return gradleLocalProperties(rootDir).getProperty(propertyKey) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 88eef9c4d..f7ab3e5de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,17 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/lighthouse/BeepApplication.kt b/app/src/main/java/com/lighthouse/BeepApplication.kt new file mode 100644 index 000000000..fd39f37bd --- /dev/null +++ b/app/src/main/java/com/lighthouse/BeepApplication.kt @@ -0,0 +1,38 @@ +package com.lighthouse + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.lighthouse.beep.BuildConfig +import com.lighthouse.presentation.background.BeepWorkManager +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber +import javax.inject.Inject + +@HiltAndroidApp +class BeepApplication : Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) { + Timber.plant(CustomTimberTree()) + } + + BeepWorkManager(this) + } + + override fun getWorkManagerConfiguration() = + Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() +} + +class CustomTimberTree : Timber.DebugTree() { + override fun createStackElementTag(element: StackTraceElement): String { + return "${element.className}:${element.lineNumber}#${element.methodName}" + } +} diff --git a/app/src/main/java/com/lighthouse/di/DataModule.kt b/app/src/main/java/com/lighthouse/di/DataModule.kt new file mode 100644 index 000000000..9b1a88ca3 --- /dev/null +++ b/app/src/main/java/com/lighthouse/di/DataModule.kt @@ -0,0 +1,116 @@ +package com.lighthouse.di + +import com.lighthouse.datasource.auth.AuthDataSource +import com.lighthouse.datasource.auth.AuthDataSourceImpl +import com.lighthouse.datasource.brand.BrandLocalDataSource +import com.lighthouse.datasource.brand.BrandLocalDataSourceImpl +import com.lighthouse.datasource.brand.BrandRemoteDataSource +import com.lighthouse.datasource.brand.BrandRemoteDataSourceImpl +import com.lighthouse.datasource.gallery.GalleryImageLocalSource +import com.lighthouse.datasource.gallery.GalleryImageLocalSourceImpl +import com.lighthouse.datasource.gifticon.GifticonLocalDataSource +import com.lighthouse.datasource.gifticon.GifticonLocalDataSourceImpl +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.BrandRepository +import com.lighthouse.domain.repository.GalleryImageRepository +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import com.lighthouse.domain.repository.GifticonRepository +import com.lighthouse.domain.repository.LocationRepository +import com.lighthouse.domain.repository.SecurityRepository +import com.lighthouse.domain.repository.UserPreferencesRepository +import com.lighthouse.repository.AuthRepositoryImpl +import com.lighthouse.repository.BrandRepositoryImpl +import com.lighthouse.repository.GalleryImageRepositoryImpl +import com.lighthouse.repository.GifticonImageRecognizeRepositoryImpl +import com.lighthouse.repository.GifticonRepositoryImpl +import com.lighthouse.repository.LocationRepositoryImpl +import com.lighthouse.repository.SecurityRepositoryImpl +import com.lighthouse.repository.UserPreferencesRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + + @Binds + @Singleton + abstract fun bindAuthDataSource( + source: AuthDataSourceImpl + ): AuthDataSource + + @Binds + @Singleton + abstract fun bindBrandRemoteDataSource( + source: BrandRemoteDataSourceImpl + ): BrandRemoteDataSource + + @Binds + @Singleton + abstract fun bindBrandLocalDataSource( + source: BrandLocalDataSourceImpl + ): BrandLocalDataSource + + @Binds + @Singleton + abstract fun bindBrandRepository( + repository: BrandRepositoryImpl + ): BrandRepository + + @Binds + @Singleton + abstract fun bindGalleryLocalDataSource( + source: GalleryImageLocalSourceImpl + ): GalleryImageLocalSource + + @Binds + @Singleton + abstract fun bindAuthRepository( + repository: AuthRepositoryImpl + ): AuthRepository + + @Binds + @Singleton + abstract fun bindGalleryImageRepository( + repository: GalleryImageRepositoryImpl + ): GalleryImageRepository + + @Binds + @Singleton + abstract fun bindGalleryImageRecognizeRepository( + repository: GifticonImageRecognizeRepositoryImpl + ): GifticonImageRecognizeRepository + + @Binds + @Singleton + abstract fun bindGifticonLocalDataSource( + source: GifticonLocalDataSourceImpl + ): GifticonLocalDataSource + + @Binds + @Singleton + abstract fun bindGifticonRepository( + repository: GifticonRepositoryImpl + ): GifticonRepository + + @Binds + @Singleton + abstract fun bindSecurityRepository( + repository: SecurityRepositoryImpl + ): SecurityRepository + + @Binds + @Singleton + abstract fun bindUserPreferencesRepository( + repository: UserPreferencesRepositoryImpl + ): UserPreferencesRepository + + @Binds + @Singleton + abstract fun bindLocationRepository( + repository: LocationRepositoryImpl + ): LocationRepository +} diff --git a/app/src/main/java/com/lighthouse/di/DatabaseModule.kt b/app/src/main/java/com/lighthouse/di/DatabaseModule.kt new file mode 100644 index 000000000..238ec7f72 --- /dev/null +++ b/app/src/main/java/com/lighthouse/di/DatabaseModule.kt @@ -0,0 +1,76 @@ +package com.lighthouse.di + +import android.content.ContentResolver +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.room.Room +import com.google.firebase.auth.FirebaseAuth +import com.lighthouse.database.BeepDatabase +import com.lighthouse.database.BeepDatabase.Companion.DATABASE_NAME +import com.lighthouse.database.dao.BrandWithSectionDao +import com.lighthouse.database.dao.GifticonDao +import com.lighthouse.domain.usecase.setting.GetSecurityOptionUseCase +import com.lighthouse.presentation.ui.security.AuthManager +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 DatabaseModule { + private const val USER_PREFERENCES = "user_preferences" + + @Provides + @Singleton + fun provideContentResolver( + @ApplicationContext context: Context + ): ContentResolver = context.contentResolver + + @Provides + @Singleton + fun provideBeepDatabase( + @ApplicationContext context: Context + ): BeepDatabase { + return Room.databaseBuilder( + context, + BeepDatabase::class.java, + DATABASE_NAME + ).build() + } + + @Provides + @Singleton + fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance() + + @Provides + @Singleton + fun provideGifticonDao( + database: BeepDatabase + ): GifticonDao = database.gifticonDao() + + @Provides + @Singleton + fun provideBrandDao( + database: BeepDatabase + ): BrandWithSectionDao = database.brandWithSectionDao() + + @Singleton + @Provides + fun providePreferencesDataStore(@ApplicationContext context: Context): DataStore { + return PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(USER_PREFERENCES) } + ) + } + + @Singleton + @Provides + fun provideAuthManger(getSecurityOptionUseCase: GetSecurityOptionUseCase): AuthManager { + return AuthManager(getSecurityOptionUseCase) + } +} diff --git a/app/src/main/java/com/lighthouse/di/HTTPRequestInterceptor.kt b/app/src/main/java/com/lighthouse/di/HTTPRequestInterceptor.kt new file mode 100644 index 000000000..ec61f3480 --- /dev/null +++ b/app/src/main/java/com/lighthouse/di/HTTPRequestInterceptor.kt @@ -0,0 +1,17 @@ +package com.lighthouse.di + +import com.lighthouse.beep.BuildConfig +import okhttp3.Interceptor +import okhttp3.Response + +class HTTPRequestInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val origin = chain.request() + val request = origin.newBuilder() + .addHeader("Content-Type", "application/json") + .addHeader("Authorization", "KakaoAK ${BuildConfig.kakaoSearchId}") + .build() + return chain.proceed(request) + } +} diff --git a/app/src/main/java/com/lighthouse/di/NetworkModule.kt b/app/src/main/java/com/lighthouse/di/NetworkModule.kt new file mode 100644 index 000000000..9496f60ae --- /dev/null +++ b/app/src/main/java/com/lighthouse/di/NetworkModule.kt @@ -0,0 +1,48 @@ +package com.lighthouse.di + +import com.lighthouse.network.NetworkApiService +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + private const val KAKAO_URL = "https://dapi.kakao.com/" + + private val moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(HTTPRequestInterceptor()) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + return Retrofit.Builder() + .client(okHttpClient) + .baseUrl(KAKAO_URL) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + } + + @Provides + @Singleton + fun provideApiService(retrofit: Retrofit): NetworkApiService { + return retrofit.create(NetworkApiService::class.java) + } +} diff --git a/app/src/main/java/com/lighthouse/di/ProviderModule.kt b/app/src/main/java/com/lighthouse/di/ProviderModule.kt new file mode 100644 index 000000000..fe2b63d78 --- /dev/null +++ b/app/src/main/java/com/lighthouse/di/ProviderModule.kt @@ -0,0 +1,35 @@ +package com.lighthouse.di + +import android.content.Context +import com.lighthouse.datasource.location.SharedLocationManager +import com.lighthouse.presentation.background.NotificationHelper +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ActivityRetainedScoped +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ProviderModule { + + @Provides + @Singleton + fun provideNotificationHelper( + @ApplicationContext context: Context + ): NotificationHelper = NotificationHelper(context) +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object ActivityRetainedProviderModule { + + @Provides + @ActivityRetainedScoped + fun provideSharedLocationManager( + context: Context + ): SharedLocationManager = SharedLocationManager(context) +} diff --git a/app/src/main/java/com/lighthouse/di/WorkManagerInitializer.kt b/app/src/main/java/com/lighthouse/di/WorkManagerInitializer.kt new file mode 100644 index 000000000..f4855b3ee --- /dev/null +++ b/app/src/main/java/com/lighthouse/di/WorkManagerInitializer.kt @@ -0,0 +1,29 @@ +package com.lighthouse.di + +import android.content.Context +import androidx.startup.Initializer +import androidx.work.Configuration +import androidx.work.WorkManager +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 WorkManagerInitializer : Initializer { + + @Provides + @Singleton + override fun create(@ApplicationContext context: Context): WorkManager { + val configuration = Configuration.Builder().build() + WorkManager.initialize(context, configuration) + return WorkManager.getInstance(context) + } + + override fun dependencies(): List>> { + return emptyList() + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 2b18b2194..0b884b1b2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,15 +1,24 @@ -buildscript { - repositories { - google() - mavenCentral() - } -} - plugins { id("com.android.application") version "7.3.1" apply false id("com.android.library") version "7.3.1" apply false id("org.jetbrains.kotlin.android") version "1.7.20" apply false id("org.jetbrains.kotlin.jvm") version "1.7.20" apply false + id("com.google.dagger.hilt.android") version "2.44" apply false +} + +buildscript { + dependencies { + classpath("com.android.tools.build:gradle:7.2.0") + classpath("com.google.gms:google-services:4.3.14") + classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.2") + classpath("com.google.android.gms:oss-licenses-plugin:0.10.6") + } +} + +allprojects { + configurations.all { + resolutionStrategy.force("org.objenesis:objenesis:2.6") + } } tasks.register("clean", Delete::class) { diff --git a/buildSrc/src/main/kotlin/AppConfig.kt b/buildSrc/src/main/kotlin/AppConfig.kt index 716bb28f6..da4e17a4a 100644 --- a/buildSrc/src/main/kotlin/AppConfig.kt +++ b/buildSrc/src/main/kotlin/AppConfig.kt @@ -3,6 +3,7 @@ object AppConfig { const val targetSdk = 33 const val minSdk = 23 const val versionCode = 1 - const val versionName = "0.0.1" + const val versionName = "1.0.0" const val buildToolsVersion = "30.0.3" + const val jvmTarget = "11" } diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 796a5047e..c1d56e1e9 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -2,51 +2,340 @@ import org.gradle.api.artifacts.dsl.DependencyHandler object Versions { const val APP_COMPAT = "1.5.1" - const val CORE = "1.7.0" + const val CORE = "1.9.0" + const val CORE_SPLASH = "1.0.0" const val CONSTRAINT_LAYOUT = "2.1.4" - const val NAVIGATION_FRAGMENT = "2.5.3" + const val MATERIAL = "1.7.0" + const val VIEWMODEL_KTX = "2.5.1" + const val FRAGMENT_KTX = "1.5.4" + const val COROUTINE = "1.6.4" + + const val ROOM = "2.4.3" + const val PAGING_KTX = "3.1.1" + + const val RETROFIT = "2.9.0" + const val MOSHI = "1.14.0" + const val JSON = "1.3.3" + + const val ZXING = "3.5.1" + + const val FIREBASE_BOM = "31.0.2" + const val TEXT_RECOGNITION_KOREAN = "16.0.0-beta6" + const val PLAY_SERVICES_AUTH = "20.3.0" + + const val BIOMETRIC = "1.1.0" + + const val WORK_MANAGER = "2.7.1" + + const val HILT = "2.44" + const val INJECT = "1" + const val HILT_WORK = "1.0.0" + + const val NAVER_MAP = "3.16.0" + const val PLAY_SERVICES_LOCATION = "20.0.0" + + const val GLIDE = "4.14.2" + const val LANDSCAPIST_GLIDE = "2.1.0" + const val VIEW_PAGER2 = "2:1.0.0" + const val JUNIT = "4.13.2" const val ANDROID_JUNIT = "1.1.3" const val ESPRESSO = "3.4.0" - const val MATERIAL = "1.7.0" + const val JUNIT5 = "5.8.2" + const val MOCK = "1.12.0" + const val GOOGLE_TRUTH = "1.1.3" + const val COROUTINES_TEST = "1.6.0" + const val MOCK_TEST = "2.28.2" + const val TURBINE = "0.12.1" + + const val TIMBER = "4.7.1" + const val DATASTORE = "1.1.0-alpha01" + + const val APP_COMPAT_THEME = "0.25.1" // 컴포즈에서 AppTheme 을 사용 + const val KOTLIN_COMPILER_EXTENSION = "1.3.2" + const val COMPOSE_BOM = "2022.10.00" + const val COMPOSE_ACTIVITIES = "1.5.1" + const val COMPOSE_VIEWMODEL = "2.5.1" + const val COMPOSE_ACCOMPANIST = "0.28.0" + + const val SHIMMER = "0.5.0" + const val LOTTIE = "5.2.0" + + const val GLANCE = "1.0.0-alpha05" + + const val OSS = "17.0.0" } object Libraries { // androidX + KTX private const val CORE = "androidx.core:core-ktx:${Versions.CORE}" + private const val CORE_SPLASH = "androidx.core:core-splashscreen:${Versions.CORE_SPLASH}" private const val APP_COMPAT = "androidx.appcompat:appcompat:${Versions.APP_COMPAT}" private const val CONSTRAINT_LAYOUT = "androidx.constraintlayout:constraintlayout:${Versions.CONSTRAINT_LAYOUT}" - private const val NAVIGATION_FRAGMENT_KTX = - "androidx.navigation:navigation-fragment-ktx:${Versions.NAVIGATION_FRAGMENT}" - private const val NAVIGATION_UI_KTX = "androidx.navigation:navigation-ui-ktx:${Versions.NAVIGATION_FRAGMENT}" private const val MATERIAL = "com.google.android.material:material:${Versions.MATERIAL}" + private const val VIEWMODEL_KTX = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.VIEWMODEL_KTX}" + private const val FRAGMENT_KTX = "androidx.fragment:fragment-ktx:${Versions.FRAGMENT_KTX}" - val VIEW_LIBRARIES = arrayListOf().apply { - add(CORE) - add(APP_COMPAT) - add(CONSTRAINT_LAYOUT) - add(NAVIGATION_FRAGMENT_KTX) - add(NAVIGATION_UI_KTX) - add(MATERIAL) - } + private const val COROUTINE_CORE = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.COROUTINE}" + private const val COROUTINE_ANDROID = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.COROUTINE}" + + private const val ROOM_RUNTIME = "androidx.room:room-runtime:${Versions.ROOM}" + private const val ROOM_KTX = "androidx.room:room-ktx:${Versions.ROOM}" + private const val ROOM_COMMON = "androidx.room:room-common:${Versions.ROOM}" + + private const val ZXING = "com.google.zxing:core:${Versions.ZXING}" + + private const val RETROFIT = "com.squareup.retrofit2:retrofit:${Versions.RETROFIT}" + private const val MOSHI_KOTLIN = "com.squareup.moshi:moshi-kotlin:${Versions.MOSHI}" + private const val MOSHI_ADAPTERS = "com.squareup.moshi:moshi-adapters:${Versions.MOSHI}" + private const val CONVERTER_MOSHI = "com.squareup.retrofit2:converter-moshi:${Versions.RETROFIT}" + private const val JSON = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.JSON}" + + private const val PAGING_COMMON_KTX = "androidx.paging:paging-common-ktx:${Versions.PAGING_KTX}" + private const val PAGING_RUNTIME_KTX = "androidx.paging:paging-runtime:${Versions.PAGING_KTX}" + + const val FIREBASE_BOM = "com.google.firebase:firebase-bom:${Versions.FIREBASE_BOM}" + const val COMPOSE_BOM = "androidx.compose:compose-bom:${Versions.COMPOSE_BOM}" + + private const val PLAY_SERVICES_AUTH = "com.google.android.gms:play-services-auth:${Versions.PLAY_SERVICES_AUTH}" + private const val FIREBASE_AUTH_KTX = "com.google.firebase:firebase-auth-ktx" + private const val FIREBASE_FIRESTORE_KTX = "com.google.firebase:firebase-firestore-ktx" + private const val FIREBASE_STORAGE_KTX = "com.google.firebase:firebase-storage-ktx" + private const val FIREBASE_CRASHLYTICS_NDK = "com.google.firebase:firebase-crashlytics-ndk" + private const val FIREBASE_CRASHLYTICS_KTX = "com.google.firebase:firebase-crashlytics-ktx" + private const val FIREBASE_ANALYTICS_KTX = "com.google.firebase:firebase-analytics-ktx" + private const val TEXT_RECOGNITION_KOREAN = "com.google.mlkit:text-recognition-korean:${Versions.TEXT_RECOGNITION_KOREAN}" + + private const val BIOMETRIC = "androidx.biometric:biometric:${Versions.BIOMETRIC}" + + private const val WORK_MANAGER = "androidx.work:work-runtime-ktx:${Versions.WORK_MANAGER}" + + private const val HILT = "com.google.dagger:hilt-android:${Versions.HILT}" + private const val INJECT = "javax.inject:javax.inject:${Versions.INJECT}" + private const val HILT_WORK = "androidx.hilt:hilt-work:${Versions.HILT_WORK}" + + private const val NAVER_MAP = "com.naver.maps:map-sdk:${Versions.NAVER_MAP}" + private const val PLAY_SERVICES_LOCATION = + "com.google.android.gms:play-services-location:${Versions.PLAY_SERVICES_LOCATION}" + + private const val GLIDE = "com.github.bumptech.glide:glide:${Versions.GLIDE}" + private const val LANDSCAPIST_GLIDE = "com.github.skydoves:landscapist-glide:${Versions.LANDSCAPIST_GLIDE}" + + private const val VIEW_PAGER2 = "androidx.viewpager2:viewpager${Versions.VIEW_PAGER2}" + + private const val TIMBER = "com.jakewharton.timber:timber:${Versions.TIMBER}" + private const val DATASTORE_CORE = "androidx.datastore:datastore-preferences-core:${Versions.DATASTORE}" + private const val DATASTORE = "androidx.datastore:datastore-preferences:${Versions.DATASTORE}" + + private const val COMPOSE_APP_COMPAT_THEME = + "com.google.accompanist:accompanist-appcompat-theme:${Versions.APP_COMPAT_THEME}" + + private const val COMPOSE_MATERIAL = "androidx.compose.material:material" + private const val COMPOSE_PREVIEW = "androidx.compose.ui:ui-tooling-preview" + private const val COMPOSE_ICONS = "androidx.compose.material:material-icons-extended" + private const val COMPOSE_ACTIVITIES = "androidx.activity:activity-compose:${Versions.COMPOSE_ACTIVITIES}" + private const val COMPOSE_VIEWMODEL = "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.COMPOSE_VIEWMODEL}" + private const val COMPOSE_LIFECYCLE_RUNTIME = "androidx.lifecycle:lifecycle-runtime-compose:+" + private const val COMPOSE_ACCOMPANIST_FLOWLAYOUT = + "com.google.accompanist:accompanist-flowlayout:${Versions.COMPOSE_ACCOMPANIST}" + private const val COMPOSE_ACCOMPANIST_PLACEHOLDER = + "com.google.accompanist:accompanist-placeholder-material:${Versions.COMPOSE_ACCOMPANIST}" + + private const val SHIMMER = "com.facebook.shimmer:shimmer:${Versions.SHIMMER}" + private const val LOTTIE = "com.airbnb.android:lottie:${Versions.LOTTIE}" + + private const val GLANCE = "androidx.glance:glance-appwidget:${Versions.GLANCE}" + + private const val OSS_LICENSES = "com.google.android.gms:play-services-oss-licenses:${Versions.OSS}" + + val VIEW_LIBRARIES = arrayListOf( + CORE, + CORE_SPLASH, + APP_COMPAT, + CONSTRAINT_LAYOUT, + MATERIAL, + COROUTINE_CORE, + COROUTINE_ANDROID, + HILT, + HILT_WORK, + VIEWMODEL_KTX, + FRAGMENT_KTX, + PAGING_RUNTIME_KTX, + FIREBASE_AUTH_KTX, + FIREBASE_CRASHLYTICS_NDK, + FIREBASE_CRASHLYTICS_KTX, + FIREBASE_ANALYTICS_KTX, + PLAY_SERVICES_AUTH, + BIOMETRIC, + NAVER_MAP, + PLAY_SERVICES_LOCATION, + GLIDE, + LANDSCAPIST_GLIDE, + ZXING, + VIEW_PAGER2, + TIMBER, + COMPOSE_APP_COMPAT_THEME, + COMPOSE_MATERIAL, + COMPOSE_PREVIEW, + COMPOSE_ICONS, + COMPOSE_ACTIVITIES, + COMPOSE_VIEWMODEL, + COMPOSE_LIFECYCLE_RUNTIME, + COMPOSE_ACCOMPANIST_FLOWLAYOUT, + COMPOSE_LIFECYCLE_RUNTIME, + COMPOSE_ACCOMPANIST_PLACEHOLDER, + SHIMMER, + LOTTIE, + WORK_MANAGER, + GLANCE, + JSON, + OSS_LICENSES + ) + val DATA_LIBRARIES = arrayListOf( + ROOM_RUNTIME, + ROOM_KTX, + COROUTINE_CORE, + RETROFIT, + MOSHI_KOTLIN, + MOSHI_ADAPTERS, + CONVERTER_MOSHI, + HILT, + HILT_WORK, + FIREBASE_AUTH_KTX, + FIREBASE_FIRESTORE_KTX, + FIREBASE_STORAGE_KTX, + TEXT_RECOGNITION_KOREAN, + WORK_MANAGER, + TIMBER, + DATASTORE, + DATASTORE_CORE, + PLAY_SERVICES_LOCATION + ) + val DOMAIN_LIBRARIES = arrayListOf( + COROUTINE_CORE, + INJECT, + PAGING_COMMON_KTX, + ROOM_COMMON + ) + val APP_LIBRARIES = arrayListOf( + HILT, + HILT_WORK, + WORK_MANAGER, + RETROFIT, + MOSHI_KOTLIN, + MOSHI_ADAPTERS, + CONVERTER_MOSHI, + ROOM_RUNTIME, + ROOM_KTX, + TIMBER, + DATASTORE, + DATASTORE_CORE, + FIREBASE_AUTH_KTX, + OSS_LICENSES + ) } object TestImpl { - private const val JUNIT4 = "junit:junit:${Versions.JUNIT}" // TODO 5 쓰는 쪽으로 바꿔야함 + private const val JUNIT4 = "junit:junit:${Versions.JUNIT}" + private const val PAGING_COMMON = "androidx.paging:paging-common:${Versions.PAGING_KTX}" - val TEST_LIBRARIES = arrayListOf().apply { - add(JUNIT4) - } + private const val JUNIT_JUPITER_PARAMS = "org.junit.jupiter:junit-jupiter-params:${Versions.JUNIT5}" + private const val JUNIT_JUPITER_ENGINE = "org.junit.jupiter:junit-jupiter-engine:${Versions.JUNIT5}" + private const val JUNIT_VINTAGE_ENGINE = "org.junit.vintage:junit-vintage-engine:${Versions.JUNIT5}" + private const val MOCK = "io.mockk:mockk:${Versions.MOCK}" + private const val GOOGLE_TRUTH = "com.google.truth:truth:${Versions.GOOGLE_TRUTH}" + private const val COROUTINES_TEST = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.COROUTINES_TEST}" + private const val TEST_CORE = "androidx.test:core:1.5.0" + private const val ROBOLECTRIC = "org.robolectric:robolectric:4.9" + private const val TURBINE = "app.cash.turbine:turbine:${Versions.TURBINE}" + + val TEST_LIBRARIES = arrayListOf( + JUNIT4, + PAGING_COMMON, + JUNIT_JUPITER_PARAMS, + JUNIT_JUPITER_ENGINE, + JUNIT_VINTAGE_ENGINE, + MOCK, + GOOGLE_TRUTH, + COROUTINES_TEST, + ROBOLECTRIC, + TURBINE + ) + + val ANDROID_TEST_LIBRARIES = arrayListOf( + TEST_CORE + ) } object AndroidTestImpl { private const val ANDROID_JUNIT = "androidx.test.ext:junit:${Versions.ANDROID_JUNIT}" private const val ESPRESSO = "androidx.test.espresso:espresso-core:${Versions.ESPRESSO}" + private const val MOCKITO_CORE = "org.mockito:mockito-core:${Versions.MOCK_TEST}" + private const val MOCKITO_ANDROID = "org.mockito:mockito-android:${Versions.MOCK_TEST}" + private const val WORK_MANAGER = "androidx.work:work-testing:${Versions.WORK_MANAGER}" - val ANDROID_LIBRARIES = arrayListOf().apply { - add(ANDROID_JUNIT) - add(ESPRESSO) - } + val VIEW_LIBRARIES = arrayListOf( + ANDROID_JUNIT, + ESPRESSO, + WORK_MANAGER + ) + + val DATA_LIBRARIES = arrayListOf( + MOCKITO_CORE, + MOCKITO_ANDROID + ) +} + +object AnnotationProcessors { + private const val ROOM_COMPILER = "androidx.room:room-compiler:${Versions.ROOM}" + private const val GLIDE_COMPILER = "com.github.bumptech.glide:compiler:${Versions.GLIDE}" + + val VIEW_LIBRARIES = arrayListOf( + GLIDE_COMPILER + ) + + val DATA_LIBRARIES = arrayListOf( + ROOM_COMPILER + ) + + val APP_LIBRARIES = arrayListOf( + ROOM_COMPILER + ) +} + +object Kapt { + private const val HILT = "com.google.dagger:hilt-android-compiler:${Versions.HILT}" + private const val HILT_WORK = "androidx.hilt:hilt-compiler:${Versions.HILT_WORK}" + + private const val ROOM_COMPILER = "androidx.room:room-compiler:${Versions.ROOM}" + private const val MOSHI_KOTLIN_CODEGEN = "com.squareup.moshi:moshi-kotlin-codegen:${Versions.MOSHI}" + + val VIEW_LIBRARIES = arrayListOf( + HILT, + HILT_WORK + ) + + val DATA_LIBRARIES = arrayListOf( + ROOM_COMPILER, + MOSHI_KOTLIN_CODEGEN, + HILT, + HILT_WORK + ) + + val APP_LIBRARIES = arrayListOf( + HILT, + HILT_WORK, + ROOM_COMPILER, + MOSHI_KOTLIN_CODEGEN + ) +} + +object DebugImpl { + private const val COMPOSE_PREVIEW_DEBUG = "androidx.compose.ui:ui-tooling" + + val VIEW_LIBRARIES = arrayListOf( + COMPOSE_PREVIEW_DEBUG + ) } fun DependencyHandler.kapt(list: List) { @@ -67,8 +356,20 @@ fun DependencyHandler.androidTestImplementation(list: List) { } } +fun DependencyHandler.annotationProcessor(list: List) { + list.forEach { dependency -> + add("annotationProcessor", dependency) + } +} + fun DependencyHandler.testImplementation(list: List) { list.forEach { dependency -> add("testImplementation", dependency) } } + +fun DependencyHandler.debugImplementation(list: List) { + list.forEach { dependency -> + add("debugImplementation", dependency) + } +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index ccaa664a2..ec673c8f2 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + kotlin("kapt") } android { @@ -34,6 +35,13 @@ android { dependencies { implementation(project(":domain")) + implementation(platform(Libraries.FIREBASE_BOM)) + implementation(Libraries.DATA_LIBRARIES) + annotationProcessor(AnnotationProcessors.DATA_LIBRARIES) + kapt(Kapt.DATA_LIBRARIES) implementation(TestImpl.TEST_LIBRARIES) - androidTestImplementation(AndroidTestImpl.ANDROID_LIBRARIES) + implementation(TestImpl.ANDROID_TEST_LIBRARIES) +} +kapt { + correctErrorTypes = true } diff --git a/data/src/main/AndroidManifest.xml b/data/src/main/AndroidManifest.xml index a5918e68a..44008a433 100644 --- a/data/src/main/AndroidManifest.xml +++ b/data/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/data/src/main/java/com/lighthouse/database/BeepDatabase.kt b/data/src/main/java/com/lighthouse/database/BeepDatabase.kt new file mode 100644 index 000000000..e4738af80 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/BeepDatabase.kt @@ -0,0 +1,42 @@ +package com.lighthouse.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.lighthouse.database.converter.DateConverter +import com.lighthouse.database.converter.DmsConverter +import com.lighthouse.database.converter.RectConverter +import com.lighthouse.database.converter.UriConverter +import com.lighthouse.database.dao.BrandWithSectionDao +import com.lighthouse.database.dao.GifticonDao +import com.lighthouse.database.entity.BrandLocationEntity +import com.lighthouse.database.entity.GifticonCropEntity +import com.lighthouse.database.entity.GifticonEntity +import com.lighthouse.database.entity.SectionEntity +import com.lighthouse.database.entity.UsageHistoryEntity + +@Database( + entities = [ + GifticonEntity::class, + GifticonCropEntity::class, + SectionEntity::class, + BrandLocationEntity::class, + UsageHistoryEntity::class + ], + version = 1 +) +@TypeConverters( + DateConverter::class, + DmsConverter::class, + RectConverter::class, + UriConverter::class +) +abstract class BeepDatabase : RoomDatabase() { + + abstract fun gifticonDao(): GifticonDao + abstract fun brandWithSectionDao(): BrandWithSectionDao + + companion object { + const val DATABASE_NAME = "beep_database" + } +} diff --git a/data/src/main/java/com/lighthouse/database/converter/DateConverter.kt b/data/src/main/java/com/lighthouse/database/converter/DateConverter.kt new file mode 100644 index 000000000..e732e938c --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/converter/DateConverter.kt @@ -0,0 +1,16 @@ +package com.lighthouse.database.converter + +import androidx.room.TypeConverter +import java.util.Date + +class DateConverter { + @TypeConverter + fun fromTimestamp(value: Long?): Date? { + return value?.let { Date(it) } + } + + @TypeConverter + fun dateToTimestamp(date: Date?): Long? { + return date?.time + } +} diff --git a/data/src/main/java/com/lighthouse/database/converter/DmsConverter.kt b/data/src/main/java/com/lighthouse/database/converter/DmsConverter.kt new file mode 100644 index 000000000..18bf61219 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/converter/DmsConverter.kt @@ -0,0 +1,18 @@ +package com.lighthouse.database.converter + +import androidx.room.TypeConverter +import com.lighthouse.domain.Dms +import com.lighthouse.domain.LocationConverter + +class DmsConverter { + + @TypeConverter + fun decimalToDms(value: Double?): Dms? { + return value?.let { LocationConverter.toMinDms(it) } + } + + @TypeConverter + fun dmsToDecimal(dms: Dms?): Double? { + return dms?.let { LocationConverter.convertToDD(it) } + } +} diff --git a/data/src/main/java/com/lighthouse/database/converter/RectConverter.kt b/data/src/main/java/com/lighthouse/database/converter/RectConverter.kt new file mode 100644 index 000000000..86f75ac82 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/converter/RectConverter.kt @@ -0,0 +1,24 @@ +package com.lighthouse.database.converter + +import android.graphics.Rect +import androidx.core.text.isDigitsOnly +import androidx.room.TypeConverter + +class RectConverter { + + @TypeConverter + fun rectToString(rect: Rect?): String? { + return rect?.let { + "${it.left},${it.top},${it.right},${it.bottom}" + } + } + + @TypeConverter + fun stringToRect(string: String?): Rect? { + val data = string?.split(",") ?: return null + if (data.size == 4 && data.all { it.isDigitsOnly() }) { + return Rect(data[0].toInt(), data[1].toInt(), data[2].toInt(), data[3].toInt()) + } + return null + } +} diff --git a/data/src/main/java/com/lighthouse/database/converter/UriConverter.kt b/data/src/main/java/com/lighthouse/database/converter/UriConverter.kt new file mode 100644 index 000000000..1933607d9 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/converter/UriConverter.kt @@ -0,0 +1,20 @@ +package com.lighthouse.database.converter + +import android.net.Uri +import androidx.room.TypeConverter + +class UriConverter { + @TypeConverter + fun fromUri(uri: Uri?): String { + return uri?.toString() ?: "" + } + + @TypeConverter + fun stringToUri(string: String?): Uri? { + string ?: return null + if (string == "") { + return null + } + return Uri.parse(string) + } +} diff --git a/data/src/main/java/com/lighthouse/database/dao/BrandWithSectionDao.kt b/data/src/main/java/com/lighthouse/database/dao/BrandWithSectionDao.kt new file mode 100644 index 000000000..9354bbe5b --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/dao/BrandWithSectionDao.kt @@ -0,0 +1,28 @@ +package com.lighthouse.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.lighthouse.database.entity.BrandLocationEntity +import com.lighthouse.database.entity.BrandWithSections +import com.lighthouse.database.entity.SectionEntity + +@Dao +interface BrandWithSectionDao { + + @Query("SELECT * FROM section_table WHERE section_id =:sectionId") + suspend fun getBrands(sectionId: String): BrandWithSections? + + @Query("SELECT section_id FROM section_table WHERE section_id =:sectionId") + suspend fun getSectionId(sectionId: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSection(sectionEntity: SectionEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertBrand(brands: List) + + @Query("DELETE FROM section_table WHERE section_id =:sectionId") + suspend fun deleteSection(sectionId: String) +} diff --git a/data/src/main/java/com/lighthouse/database/dao/GifticonDao.kt b/data/src/main/java/com/lighthouse/database/dao/GifticonDao.kt new file mode 100644 index 000000000..3def4f546 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/dao/GifticonDao.kt @@ -0,0 +1,193 @@ +package com.lighthouse.database.dao + +import android.graphics.Rect +import android.net.Uri +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.lighthouse.database.entity.GifticonCropEntity +import com.lighthouse.database.entity.GifticonCropEntity.Companion.GIFTICON_CROP_TABLE +import com.lighthouse.database.entity.GifticonEntity +import com.lighthouse.database.entity.GifticonEntity.Companion.GIFTICON_TABLE +import com.lighthouse.database.entity.GifticonWithCrop +import com.lighthouse.database.entity.UsageHistoryEntity +import com.lighthouse.database.entity.UsageHistoryEntity.Companion.USAGE_HISTORY_TABLE +import com.lighthouse.database.mapper.toGifticonCropEntity +import com.lighthouse.database.mapper.toGifticonEntity +import com.lighthouse.domain.model.Brand +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import java.util.Date + +@Dao +interface GifticonDao { + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE id = :id") + fun getGifticon(id: String): Flow + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE user_id = :userId AND is_used = 0 ORDER BY created_at DESC") + fun getAllGifticons(userId: String): Flow> + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE user_id = :userId AND is_used = 1 ORDER BY created_at DESC") + fun getAllUsedGifticons(userId: String): Flow> + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE user_id = :userId AND expire_at >= :time AND is_used = 0 ORDER BY expire_at") + fun getAllUsableGifticons(userId: String, time: Date): Flow> + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE user_id = :userId AND is_used = 0 ORDER BY expire_at") + fun getAllGifticonsSortByDeadline(userId: String): Flow> + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE user_id = :userId AND is_used = 0 AND UPPER(brand) IN(:filters) ORDER BY created_at DESC") + fun getFilteredGifticons(userId: String, filters: Set): Flow> + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE user_id = :userId AND is_used = 0 AND UPPER(brand) IN(:filters) ORDER BY expire_at") + fun getFilteredGifticonsSortByDeadline(userId: String, filters: Set): Flow> + + @Query( + "SELECT * FROM $GIFTICON_TABLE " + + "INNER JOIN $GIFTICON_CROP_TABLE " + + "ON $GIFTICON_TABLE.id = $GIFTICON_CROP_TABLE.gifticon_id " + + "WHERE id = :id AND user_id = :userId " + + "LIMIT 1" + ) + suspend fun getGifticonWithCrop(userId: String, id: String): GifticonWithCrop? + + @Query( + "SELECT brand AS name, COUNT(*) AS count " + + "FROM $GIFTICON_TABLE " + + "WHERE user_id = :userId " + + "GROUP BY brand ORDER BY count DESC" + ) + fun getAllBrands(userId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGifticon(gifticon: GifticonEntity) + + @Insert + suspend fun insertGifticonCrop(cropEntity: GifticonCropEntity) + + @Transaction + suspend fun insertGifticonWithCropTransaction(gifticonWithCropList: List) { + gifticonWithCropList.forEach { + insertGifticon(it.toGifticonEntity()) + insertGifticonCrop(it.toGifticonCropEntity()) + } + } + + /** + * 기프티콘을 사용 상태로 변경한다 + * */ + @Query("UPDATE $GIFTICON_TABLE SET is_used = 1 WHERE id = :gifticonId") + suspend fun useGifticon(gifticonId: String) + + /** + * 기프티콘을 사용 안 됨 상태로 변경한다 + * */ + @Query("UPDATE $GIFTICON_TABLE SET is_used = 0 WHERE id = :gifticonId") + suspend fun unUseGifticon(gifticonId: String) + + /** + * 금액권 기프티콘의 잔액을 차감한다 + * */ + @Query("UPDATE $GIFTICON_TABLE SET balance = :balance WHERE id = :gifticonId") + fun useCashCardGifticon(gifticonId: String, balance: Int) + + /** + * 기프티콘을 삭제한다 + */ + @Query("DELETE FROM $GIFTICON_TABLE WHERE id = :gifticonId") + suspend fun removeGifticon(gifticonId: String) + + /** + * 기프티콘의 사용 기록을 조회한다 + * */ + @Query("SELECT * FROM $USAGE_HISTORY_TABLE WHERE gifticon_id = :gifticonId") + fun getUsageHistory(gifticonId: String): Flow> + + /** + * 기프티콘의 사용 기록을 추가한다 + * */ + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertUsageHistory(usageHistory: UsageHistoryEntity) + + /** + * 기프티콘의 정보를 업데이트한다 + * */ + @Query( + "UPDATE $GIFTICON_TABLE " + + "SET cropped_uri = :croppedUri, " + + "name = :name, " + + "brand = :brand, " + + "expire_at = :expire_at, " + + "barcode = :barcode, " + + "is_cash_card = :isCashCard, " + + "balance = :balance, " + + "memo = :memo " + + "WHERE id = :id" + ) + suspend fun updateGifticon( + id: String, + croppedUri: Uri?, + name: String, + brand: String, + expire_at: Date, + barcode: String, + isCashCard: Boolean, + balance: Int, + memo: String + ) + + @Query("UPDATE $GIFTICON_CROP_TABLE SET cropped_rect = :croppedRect WHERE gifticon_id = :id") + suspend fun updateGifticonCrop(id: String, croppedRect: Rect) + + @Transaction + suspend fun updateGifticonWithCropTransaction(gifticonWithCrop: GifticonWithCrop) { + with(gifticonWithCrop) { + updateGifticon(id, croppedUri, name, brand, expireAt, barcode, isCashCard, balance, memo) + updateGifticonCrop(id, croppedRect) + } + } + + /** + * 기프티콘을 사용 상태로 변경하고, 사용 기록에 추가한다 + * */ + @Transaction + suspend fun useGifticonTransaction(usageHistory: UsageHistoryEntity) { + val gifticonId = usageHistory.gifticonId + + useGifticon(gifticonId) + insertUsageHistory(usageHistory) + } + + /** + * 금액권 기프티콘의 잔액을 차감하고 사용 기록에 추가한다. 잔액이 0원이 된다면 사용 상태로 변경한다 + * */ + @Transaction + suspend fun useCashCardGifticonTransaction(amount: Int, usageHistory: UsageHistoryEntity) { + val gifticonId = usageHistory.gifticonId + val balance = getGifticon(gifticonId).first().balance + + assert(balance >= amount) // 사용할 금액이 잔액보다 많으면 안된다 + + useCashCardGifticon(gifticonId, balance - amount) + insertUsageHistory(usageHistory) + + if (balance == amount) { + useGifticon(gifticonId) + } + } + + @Query("SELECT * FROM $GIFTICON_TABLE WHERE brand =:brand") + fun getGifticonByBrand(brand: String): Flow> + + @Query("SELECT EXISTS (SELECT * FROM $GIFTICON_TABLE WHERE expire_at >= :time AND is_used = 0 AND user_id = :userId)") + fun hasUsableGifticon(userId: String, time: Date): Flow + + @Query("SELECT EXISTS(SELECT 1 from $GIFTICON_TABLE WHERE LOWER(brand)=LOWER(:brand) LIMIT 1)") + suspend fun hasGifticonBrand(brand: String): Boolean + + @Query("UPDATE $GIFTICON_TABLE SET user_id = :newUserId WHERE user_id = :oldUserId") + suspend fun moveUserIdGifticon(oldUserId: String, newUserId: String) +} diff --git a/data/src/main/java/com/lighthouse/database/entity/BrandLocationEntity.kt b/data/src/main/java/com/lighthouse/database/entity/BrandLocationEntity.kt new file mode 100644 index 000000000..9fae40636 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/entity/BrandLocationEntity.kt @@ -0,0 +1,36 @@ +package com.lighthouse.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.lighthouse.database.entity.BrandLocationEntity.Companion.BRAND_LOCATION_TABLE + +@Entity( + tableName = BRAND_LOCATION_TABLE, + foreignKeys = [ + ForeignKey( + entity = SectionEntity::class, + parentColumns = arrayOf("section_id"), + childColumns = arrayOf("parent_section_id"), + onDelete = ForeignKey.CASCADE + ) + ] +) +data class BrandLocationEntity( + @PrimaryKey + @ColumnInfo(name = "place_url") + val placeUrl: String, + @ColumnInfo(name = "address_name") val addressName: String, + @ColumnInfo(name = "parent_section_id") val sectionId: String, + @ColumnInfo(name = "place_name") val placeName: String, + @ColumnInfo(name = "category_name") val categoryName: String, + @ColumnInfo(name = "brand") val brand: String, + @ColumnInfo(name = "x") val x: String, + @ColumnInfo(name = "y") val y: String +) { + + companion object { + const val BRAND_LOCATION_TABLE = "brand_location_table" + } +} diff --git a/data/src/main/java/com/lighthouse/database/entity/BrandWithSections.kt b/data/src/main/java/com/lighthouse/database/entity/BrandWithSections.kt new file mode 100644 index 000000000..e959201df --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/entity/BrandWithSections.kt @@ -0,0 +1,19 @@ +package com.lighthouse.database.entity + +import androidx.room.Embedded +import androidx.room.Relation + +data class BrandWithSections( + @Embedded val sectionEntity: SectionEntity, + @Relation( + parentColumn = PARENT_COLUMN_ID, + entityColumn = ENTITY_COLUMN_ID + ) + val brands: List +) { + + companion object { + private const val PARENT_COLUMN_ID = "section_id" + private const val ENTITY_COLUMN_ID = "parent_section_id" + } +} diff --git a/data/src/main/java/com/lighthouse/database/entity/GifticonCropEntity.kt b/data/src/main/java/com/lighthouse/database/entity/GifticonCropEntity.kt new file mode 100644 index 000000000..3a6c1514f --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/entity/GifticonCropEntity.kt @@ -0,0 +1,31 @@ +package com.lighthouse.database.entity + +import android.graphics.Rect +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import com.lighthouse.database.entity.GifticonCropEntity.Companion.GIFTICON_CROP_TABLE + +@Entity( + tableName = GIFTICON_CROP_TABLE, + foreignKeys = [ + ForeignKey( + entity = GifticonEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("gifticon_id"), + onDelete = ForeignKey.CASCADE + ) + ] +) +data class GifticonCropEntity( + @PrimaryKey + @ColumnInfo(name = "gifticon_id") + val gifticonId: String, + @ColumnInfo(name = "cropped_rect") + val croppedRect: Rect +) { + companion object { + const val GIFTICON_CROP_TABLE = "gifticon_crop_table" + } +} diff --git a/data/src/main/java/com/lighthouse/database/entity/GifticonEntity.kt b/data/src/main/java/com/lighthouse/database/entity/GifticonEntity.kt new file mode 100644 index 000000000..5f3b82c48 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/entity/GifticonEntity.kt @@ -0,0 +1,31 @@ +package com.lighthouse.database.entity + +import android.net.Uri +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.lighthouse.database.entity.GifticonEntity.Companion.GIFTICON_TABLE +import java.util.Date + +@Entity(tableName = GIFTICON_TABLE) +data class GifticonEntity( + @PrimaryKey + @ColumnInfo(name = "id") + val id: String, + @ColumnInfo(name = "created_at") val createdAt: Date, + @ColumnInfo(name = "user_id") val userId: String, + @ColumnInfo(name = "has_image") val hasImage: Boolean, + @ColumnInfo(name = "cropped_uri") val croppedUri: Uri?, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "brand") val brand: String, + @ColumnInfo(name = "expire_at") val expireAt: Date, + @ColumnInfo(name = "barcode") val barcode: String, + @ColumnInfo(name = "is_cash_card") val isCashCard: Boolean, + @ColumnInfo(name = "balance") val balance: Int, + @ColumnInfo(name = "memo") val memo: String, + @ColumnInfo(name = "is_used") val isUsed: Boolean +) { + companion object { + const val GIFTICON_TABLE = "gifticon_table" + } +} diff --git a/data/src/main/java/com/lighthouse/database/entity/GifticonWithCrop.kt b/data/src/main/java/com/lighthouse/database/entity/GifticonWithCrop.kt new file mode 100644 index 000000000..f5c8f54d0 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/entity/GifticonWithCrop.kt @@ -0,0 +1,23 @@ +package com.lighthouse.database.entity + +import android.graphics.Rect +import android.net.Uri +import androidx.room.ColumnInfo +import java.util.Date + +class GifticonWithCrop( + @ColumnInfo(name = "id") val id: String, + @ColumnInfo(name = "user_id") val userId: String, + @ColumnInfo(name = "has_image") val hasImage: Boolean, + @ColumnInfo(name = "cropped_uri") val croppedUri: Uri?, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "brand") val brand: String, + @ColumnInfo(name = "expire_at") val expireAt: Date, + @ColumnInfo(name = "barcode") val barcode: String, + @ColumnInfo(name = "is_cash_card") val isCashCard: Boolean, + @ColumnInfo(name = "balance") val balance: Int, + @ColumnInfo(name = "memo") val memo: String, + @ColumnInfo(name = "cropped_rect") val croppedRect: Rect, + @ColumnInfo(name = "is_used") val isUsed: Boolean, + @ColumnInfo(name = "created_at") val createdAt: Date +) diff --git a/data/src/main/java/com/lighthouse/database/entity/SectionEntity.kt b/data/src/main/java/com/lighthouse/database/entity/SectionEntity.kt new file mode 100644 index 000000000..147baf4b4 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/entity/SectionEntity.kt @@ -0,0 +1,23 @@ +package com.lighthouse.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.lighthouse.database.entity.SectionEntity.Companion.SECTION_TABLE +import com.lighthouse.domain.Dms +import java.util.Date + +@Entity(tableName = SECTION_TABLE) +data class SectionEntity( + @PrimaryKey + @ColumnInfo(name = "section_id") + val id: String, + @ColumnInfo(name = "search_date") val searchDate: Date, + @ColumnInfo(name = "x") val x: Dms, + @ColumnInfo(name = "y") val y: Dms +) { + + companion object { + const val SECTION_TABLE = "section_table" + } +} diff --git a/data/src/main/java/com/lighthouse/database/entity/UsageHistoryEntity.kt b/data/src/main/java/com/lighthouse/database/entity/UsageHistoryEntity.kt new file mode 100644 index 000000000..6a31dac21 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/entity/UsageHistoryEntity.kt @@ -0,0 +1,31 @@ +package com.lighthouse.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import com.lighthouse.database.entity.UsageHistoryEntity.Companion.USAGE_HISTORY_TABLE +import java.util.Date + +@Entity( + tableName = USAGE_HISTORY_TABLE, + primaryKeys = ["gifticon_id", "date"], + foreignKeys = [ + ForeignKey( + entity = GifticonEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("gifticon_id"), + onDelete = ForeignKey.CASCADE + ) + ] +) +data class UsageHistoryEntity( + @ColumnInfo(name = "gifticon_id") val gifticonId: String, + @ColumnInfo(name = "date") val date: Date, + @ColumnInfo(name = "longitude") val longitude: Double, + @ColumnInfo(name = "latitude") val latitude: Double, + @ColumnInfo(name = "amount") val amount: Int +) { + companion object { + const val USAGE_HISTORY_TABLE = "usage_history_table" + } +} diff --git a/data/src/main/java/com/lighthouse/database/mapper/GifticonCropEntityMapper.kt b/data/src/main/java/com/lighthouse/database/mapper/GifticonCropEntityMapper.kt new file mode 100644 index 000000000..33bff9246 --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/mapper/GifticonCropEntityMapper.kt @@ -0,0 +1,12 @@ +package com.lighthouse.database.mapper + +import com.lighthouse.database.entity.GifticonCropEntity +import com.lighthouse.domain.model.GifticonCrop +import com.lighthouse.mapper.toDomain + +fun GifticonCropEntity.toDomain(): GifticonCrop { + return GifticonCrop( + gifticonId = gifticonId, + rect = croppedRect.toDomain() + ) +} diff --git a/data/src/main/java/com/lighthouse/database/mapper/GifticonForAddtionMapper.kt b/data/src/main/java/com/lighthouse/database/mapper/GifticonForAddtionMapper.kt new file mode 100644 index 000000000..a8cc58c1f --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/mapper/GifticonForAddtionMapper.kt @@ -0,0 +1,33 @@ +package com.lighthouse.database.mapper + +import com.lighthouse.database.entity.GifticonWithCrop +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.mapper.toEntity +import com.lighthouse.model.GifticonImageResult +import java.util.Date + +fun GifticonForAddition.toEntity( + id: String, + userId: String, + result: GifticonImageResult? +): GifticonWithCrop { + return GifticonWithCrop( + id = id, + userId = userId, + hasImage = hasImage, + croppedUri = result?.outputCroppedUri, + name = name, + brand = brandName, + expireAt = expiredAt, + barcode = barcode, + isCashCard = isCashCard, + balance = balance, + memo = memo, + croppedRect = croppedRect.toEntity().apply { + val sampleSize = result?.sampleSize ?: 1 + set(left / sampleSize, top / sampleSize, right / sampleSize, bottom / sampleSize) + }, + isUsed = false, + createdAt = Date() + ) +} diff --git a/data/src/main/java/com/lighthouse/database/mapper/GifticonForUpdateMapper.kt b/data/src/main/java/com/lighthouse/database/mapper/GifticonForUpdateMapper.kt new file mode 100644 index 000000000..6ab11ea2e --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/mapper/GifticonForUpdateMapper.kt @@ -0,0 +1,25 @@ +package com.lighthouse.database.mapper + +import android.net.Uri +import com.lighthouse.database.entity.GifticonWithCrop +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.mapper.toEntity + +fun GifticonForUpdate.toEntity(newCroppedUri: Uri?): GifticonWithCrop { + return GifticonWithCrop( + id = id, + userId = userId, + hasImage = hasImage, + croppedUri = newCroppedUri ?: if (croppedUri.isNotEmpty()) Uri.parse(croppedUri) else null, + name = name, + brand = brandName, + expireAt = expiredAt, + barcode = barcode, + isCashCard = isCashCard, + balance = balance, + memo = memo, + croppedRect = croppedRect.toEntity(), + isUsed = isUsed, + createdAt = createdAt + ) +} diff --git a/data/src/main/java/com/lighthouse/database/mapper/GifticonWithCropMapper.kt b/data/src/main/java/com/lighthouse/database/mapper/GifticonWithCropMapper.kt new file mode 100644 index 000000000..3d4df940e --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/mapper/GifticonWithCropMapper.kt @@ -0,0 +1,52 @@ +package com.lighthouse.database.mapper + +import com.lighthouse.database.entity.GifticonCropEntity +import com.lighthouse.database.entity.GifticonEntity +import com.lighthouse.database.entity.GifticonWithCrop +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.mapper.toDomain + +fun GifticonWithCrop.toDomain(): GifticonForUpdate { + return GifticonForUpdate( + id = id, + userId = userId, + hasImage = hasImage, + name = name, + brandName = brand, + barcode = barcode, + expiredAt = expireAt, + isCashCard = isCashCard, + balance = balance, + oldCroppedUri = croppedUri.toString(), + croppedUri = croppedUri.toString(), + croppedRect = croppedRect.toDomain(), + memo = memo, + isUsed = isUsed, + createdAt = createdAt + ) +} + +fun GifticonWithCrop.toGifticonEntity(): GifticonEntity { + return GifticonEntity( + id = id, + userId = userId, + hasImage = hasImage, + croppedUri = croppedUri, + name = name, + brand = brand, + expireAt = expireAt, + barcode = barcode, + isCashCard = isCashCard, + balance = balance, + memo = memo, + isUsed = isUsed, + createdAt = createdAt + ) +} + +fun GifticonWithCrop.toGifticonCropEntity(): GifticonCropEntity { + return GifticonCropEntity( + gifticonId = id, + croppedRect = croppedRect + ) +} diff --git a/data/src/main/java/com/lighthouse/database/mapper/UsageHistoryMapper.kt b/data/src/main/java/com/lighthouse/database/mapper/UsageHistoryMapper.kt new file mode 100644 index 000000000..2a9a33b5b --- /dev/null +++ b/data/src/main/java/com/lighthouse/database/mapper/UsageHistoryMapper.kt @@ -0,0 +1,23 @@ +package com.lighthouse.database.mapper + +import com.lighthouse.database.entity.UsageHistoryEntity +import com.lighthouse.domain.VertexLocation +import com.lighthouse.domain.model.UsageHistory + +fun UsageHistoryEntity.toUsageHistory(): UsageHistory { + return UsageHistory( + date = date, + location = VertexLocation(longitude, latitude), + amount = amount + ) +} + +fun UsageHistory.toUsageHistoryEntity(gifticonId: String): UsageHistoryEntity { + return UsageHistoryEntity( + gifticonId = gifticonId, + date = date, + longitude = location?.longitude ?: 0f.toDouble(), + latitude = location?.latitude ?: 0f.toDouble(), + amount = amount + ) +} diff --git a/data/src/main/java/com/lighthouse/datasource/auth/AuthDataSource.kt b/data/src/main/java/com/lighthouse/datasource/auth/AuthDataSource.kt new file mode 100644 index 000000000..371c01b0f --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/auth/AuthDataSource.kt @@ -0,0 +1,6 @@ +package com.lighthouse.datasource.auth + +interface AuthDataSource { + + fun getCurrentUserId(): String +} diff --git a/data/src/main/java/com/lighthouse/datasource/auth/AuthDataSourceImpl.kt b/data/src/main/java/com/lighthouse/datasource/auth/AuthDataSourceImpl.kt new file mode 100644 index 000000000..a49310c00 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/auth/AuthDataSourceImpl.kt @@ -0,0 +1,17 @@ +package com.lighthouse.datasource.auth + +import com.google.firebase.auth.FirebaseAuth +import javax.inject.Inject + +class AuthDataSourceImpl @Inject constructor( + private val firebaseAuth: FirebaseAuth +) : AuthDataSource { + + override fun getCurrentUserId(): String { + return firebaseAuth.currentUser?.uid ?: GUEST_ID + } + + companion object { + private const val GUEST_ID = "Guest" + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/brand/BrandLocalDataSource.kt b/data/src/main/java/com/lighthouse/datasource/brand/BrandLocalDataSource.kt new file mode 100644 index 000000000..3ac5d32e9 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/brand/BrandLocalDataSource.kt @@ -0,0 +1,14 @@ +package com.lighthouse.datasource.brand + +import com.lighthouse.database.entity.BrandLocationEntity +import com.lighthouse.domain.Dms +import com.lighthouse.domain.model.BrandPlaceInfo + +interface BrandLocalDataSource { + + suspend fun getBrands(x: Dms, y: Dms, brandName: String): Result> + + suspend fun insertBrands(brandPlaceInfos: List, x: Dms, y: Dms, brandName: String) + + suspend fun isNearBrand(x: Dms, y: Dms, brandName: String): List? +} diff --git a/data/src/main/java/com/lighthouse/datasource/brand/BrandLocalDataSourceImpl.kt b/data/src/main/java/com/lighthouse/datasource/brand/BrandLocalDataSourceImpl.kt new file mode 100644 index 000000000..216fcdd11 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/brand/BrandLocalDataSourceImpl.kt @@ -0,0 +1,66 @@ +package com.lighthouse.datasource.brand + +import com.lighthouse.database.dao.BrandWithSectionDao +import com.lighthouse.database.entity.BrandLocationEntity +import com.lighthouse.database.entity.SectionEntity +import com.lighthouse.domain.Dms +import com.lighthouse.domain.LocationConverter +import com.lighthouse.domain.model.BrandPlaceInfo +import com.lighthouse.mapper.toEntity +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Date +import javax.inject.Inject + +class BrandLocalDataSourceImpl @Inject constructor( + private val brandWithSectionDao: BrandWithSectionDao +) : BrandLocalDataSource { + + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + override suspend fun getBrands( + x: Dms, + y: Dms, + brandName: String + ): Result> { + val sectionResults = + brandWithSectionDao.getBrands(combineSectionId(x.dmsToString(), y.dmsToString(), brandName)) + return if (sectionResults == null) { + Result.failure(Exception()) + } else { + Result.success(sectionResults.brands) + } + } + + override suspend fun insertBrands( + brandPlaceInfos: List, + x: Dms, + y: Dms, + brandName: String + ) = withContext(ioDispatcher) { + val searchCardinalDirections = LocationConverter.getSearchCardinalDirections(x, y) + searchCardinalDirections.forEach { location -> + brandWithSectionDao.insertSection( + SectionEntity( + combineSectionId(location.x.dmsToString(), location.y.dmsToString(), brandName), + Date(), + location.x, + location.y + ) + ) + } + brandWithSectionDao.insertBrand(brandPlaceInfos.toEntity()) + } + + override suspend fun isNearBrand(x: Dms, y: Dms, brandName: String): List? { + val sectionResults = + brandWithSectionDao.getBrands(combineSectionId(x.dmsToString(), y.dmsToString(), brandName)) ?: return null + + return sectionResults.brands + } + + companion object { + fun combineSectionId(x: String, y: String, brandName: String) = "${x}_${y}_${brandName.lowercase()}" + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/brand/BrandRemoteDataSource.kt b/data/src/main/java/com/lighthouse/datasource/brand/BrandRemoteDataSource.kt new file mode 100644 index 000000000..b5d0697c4 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/brand/BrandRemoteDataSource.kt @@ -0,0 +1,14 @@ +package com.lighthouse.datasource.brand + +import com.lighthouse.domain.Dms +import com.lighthouse.model.BrandPlaceInfoDataContainer + +interface BrandRemoteDataSource { + + suspend fun getBrandPlaceInfo( + brandName: String, + x: Dms, + y: Dms, + size: Int + ): Result> +} diff --git a/data/src/main/java/com/lighthouse/datasource/brand/BrandRemoteDataSourceImpl.kt b/data/src/main/java/com/lighthouse/datasource/brand/BrandRemoteDataSourceImpl.kt new file mode 100644 index 000000000..20991875c --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/brand/BrandRemoteDataSourceImpl.kt @@ -0,0 +1,30 @@ +package com.lighthouse.datasource.brand + +import com.lighthouse.domain.Dms +import com.lighthouse.domain.LocationConverter +import com.lighthouse.model.BeepErrorData +import com.lighthouse.model.BrandPlaceInfoDataContainer +import com.lighthouse.network.NetworkApiService +import java.net.UnknownHostException +import javax.inject.Inject + +class BrandRemoteDataSourceImpl @Inject constructor( + private val networkApiService: NetworkApiService +) : BrandRemoteDataSource { + + override suspend fun getBrandPlaceInfo( + brandName: String, + x: Dms, + y: Dms, + size: Int + ): Result> { + val vertex = LocationConverter.getVertex(x, y) + + val result = runCatching { networkApiService.getAllBrandPlaceInfo(brandName, vertex, size).documents } + return when (val exception = result.exceptionOrNull()) { + null -> result + is UnknownHostException -> Result.failure(BeepErrorData.NetworkFailure) + else -> Result.failure(exception) + } + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImageLocalSource.kt b/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImageLocalSource.kt new file mode 100644 index 000000000..e5e990e17 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImageLocalSource.kt @@ -0,0 +1,7 @@ +package com.lighthouse.datasource.gallery + +import com.lighthouse.domain.model.GalleryImage + +interface GalleryImageLocalSource { + suspend fun getImages(page: Int, limit: Int): List +} diff --git a/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImageLocalSourceImpl.kt b/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImageLocalSourceImpl.kt new file mode 100644 index 000000000..7d84de18d --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImageLocalSourceImpl.kt @@ -0,0 +1,70 @@ +package com.lighthouse.datasource.gallery + +import android.content.ContentResolver +import android.content.ContentUris +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.webkit.MimeTypeMap +import com.lighthouse.domain.model.GalleryImage +import java.util.Date +import javax.inject.Inject + +class GalleryImageLocalSourceImpl @Inject constructor( + private val contentResolver: ContentResolver +) : GalleryImageLocalSource { + + override suspend fun getImages(page: Int, limit: Int): List { + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DATE_ADDED + ) + val selection = "${MediaStore.Images.Media.MIME_TYPE} in (?,?)" + val mimeTypeMap = MimeTypeMap.getSingleton() + val selectionArg = arrayOf( + mimeTypeMap.getMimeTypeFromExtension("png"), + mimeTypeMap.getMimeTypeFromExtension("jpg") + ) + val offset = page * limit + val cursor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val queryArgs = Bundle().apply { + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArg) + putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(MediaStore.Images.Media.DATE_ADDED)) + putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING) + putInt(ContentResolver.QUERY_ARG_OFFSET, offset) + putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + } + contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + queryArgs, + null + ) + } else { + val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC LIMIT $limit OFFSET $offset" + contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArg, + sortOrder + ) + } + + val list = ArrayList() + cursor?.use { + val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + val dateAddedColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED) + + while (it.moveToNext()) { + val id = it.getLong(idColumn) + val contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + val dateAdded = it.getLong(dateAddedColumn) + val date = Date(dateAdded * 1000) + list.add(GalleryImage(id, contentUri.toString(), date)) + } + } + return list + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImagePagingSource.kt b/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImagePagingSource.kt new file mode 100644 index 000000000..9839e5110 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/gallery/GalleryImagePagingSource.kt @@ -0,0 +1,33 @@ +package com.lighthouse.datasource.gallery + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.lighthouse.domain.model.GalleryImage + +class GalleryImagePagingSource( + private val localSource: GalleryImageLocalSource, + private val page: Int, + private val limit: Int +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + override suspend fun load(params: LoadParams): LoadResult { + val current = params.key ?: page + val results = localSource.getImages(current, params.loadSize) + return try { + LoadResult.Page( + data = results, + prevKey = null, + nextKey = if (results.size < params.loadSize) null else current + (params.loadSize / limit) + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonImageRecognizeSource.kt b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonImageRecognizeSource.kt new file mode 100644 index 000000000..ea4cca6e2 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonImageRecognizeSource.kt @@ -0,0 +1,109 @@ +package com.lighthouse.datasource.gifticon + +import android.content.ContentResolver.SCHEME_CONTENT +import android.content.ContentResolver.SCHEME_FILE +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.core.net.toUri +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.mapper.toDomain +import com.lighthouse.util.recognizer.BalanceRecognizer +import com.lighthouse.util.recognizer.BarcodeRecognizer +import com.lighthouse.util.recognizer.ExpiredRecognizer +import com.lighthouse.util.recognizer.GifticonRecognizer +import com.lighthouse.util.recognizer.TextRecognizer +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.util.Date +import javax.inject.Inject + +class GifticonImageRecognizeSource @Inject constructor( + @ApplicationContext private val context: Context +) { + + suspend fun recognize(id: Long, uri: Uri?): GifticonForAddition? { + uri ?: return null + val originBitmap = decodeBitmap(uri) ?: return null + val info = GifticonRecognizer().recognize(originBitmap) + val croppedBitmap = info.croppedImage + var croppedUri: Uri? = null + if (croppedBitmap != null) { + val croppedFile = context.getFileStreamPath("$TEMP_CROPPED_PREFIX$id") + saveBitmap(croppedBitmap, CompressFormat.JPEG, 100, croppedFile) + croppedUri = croppedFile.toUri() + } + return info.toDomain(uri, croppedUri) + } + + suspend fun recognizeGifticonName(uri: Uri?): String { + uri ?: return "" + val bitmap = decodeBitmap(uri) ?: return "" + val inputs = TextRecognizer().recognize(bitmap) + return inputs.joinToString("") + } + + suspend fun recognizeBrandName(uri: Uri?): String { + uri ?: return "" + val bitmap = decodeBitmap(uri) ?: return "" + val inputs = TextRecognizer().recognize(bitmap) + return inputs.joinToString("") + } + + suspend fun recognizeBarcode(uri: Uri?): String { + uri ?: return "" + val bitmap = decodeBitmap(uri) ?: return "" + return BarcodeRecognizer().recognize(bitmap).barcode + } + + suspend fun recognizeBalance(uri: Uri?): Int { + uri ?: return 0 + val bitmap = decodeBitmap(uri) ?: return 0 + val result = BalanceRecognizer().recognize(bitmap) + return result.balance + } + + suspend fun recognizeExpired(uri: Uri?): Date { + uri ?: return Date(0) + val bitmap = decodeBitmap(uri) ?: return Date(0) + val result = ExpiredRecognizer().recognize(bitmap) + return result.expired + } + + private suspend fun decodeBitmap(uri: Uri): Bitmap? { + return withContext(Dispatchers.IO) { + when (uri.scheme) { + SCHEME_CONTENT -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.isMutableRequired = true + } + } else { + MediaStore.Images.Media.getBitmap(context.contentResolver, uri) + } + SCHEME_FILE -> BitmapFactory.decodeFile(uri.path, BitmapFactory.Options()) + else -> null + } + } + } + + private suspend fun saveBitmap(bitmap: Bitmap, format: CompressFormat, quality: Int, file: File) { + withContext(Dispatchers.IO) { + FileOutputStream(file).use { output -> + bitmap.compress(format, quality, output) + } + } + } + + companion object { + private const val TEMP_CROPPED_PREFIX = "temp_gifticon_" + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonImageSource.kt b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonImageSource.kt new file mode 100644 index 000000000..182c7adbc --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonImageSource.kt @@ -0,0 +1,167 @@ +package com.lighthouse.datasource.gifticon + +import android.content.ContentResolver.SCHEME_CONTENT +import android.content.ContentResolver.SCHEME_FILE +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.core.net.toFile +import androidx.core.net.toUri +import com.lighthouse.model.GifticonImageResult +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.util.Date +import javax.inject.Inject + +class GifticonImageSource @Inject constructor( + @ApplicationContext private val context: Context +) { + + private val screenWidth = context.resources.displayMetrics.widthPixels + private val screenHeight = context.resources.displayMetrics.heightPixels + + suspend fun saveImage(id: String, originUri: Uri?, oldCroppedUri: Uri?): GifticonImageResult? { + originUri ?: return null + oldCroppedUri ?: return null + + val outputOriginFile = context.getFileStreamPath("$ORIGIN_PREFIX$id") + + val inputOriginStream = openInputStream(originUri) ?: return null + val sampleSize = calculateSampleSize(inputOriginStream) + + val originBitmap = decodeBitmap(originUri) ?: return null + val sampledOriginBitmap = samplingBitmap(originBitmap, sampleSize) + saveBitmap(sampledOriginBitmap, CompressFormat.JPEG, 100, outputOriginFile) + + val updated = Date() + val outputCroppedFile = context.getFileStreamPath("${CROPPED_PREFIX}$id${updated.time}") + val cropped = if (exists(oldCroppedUri)) { + decodeBitmap(oldCroppedUri).also { deleteIfFile(oldCroppedUri) } ?: return null + } else { + centerCropBitmap(sampledOriginBitmap, 1f) + } + saveBitmap(cropped, CompressFormat.JPEG, QUALITY, outputCroppedFile) + return GifticonImageResult(sampleSize, outputCroppedFile.toUri()) + } + + suspend fun updateImage(id: String, oldCroppedUri: Uri?, newCroppedUri: Uri?): Uri? { + oldCroppedUri ?: return null + newCroppedUri ?: return null + deleteIfFile(oldCroppedUri) + + val updated = Date() + val outputCropped = context.getFileStreamPath("$CROPPED_PREFIX$id${updated.time}") + withContext(Dispatchers.IO) { + openInputStream(newCroppedUri)?.use { input -> + FileOutputStream(outputCropped).use { output -> + input.copyTo(output) + } + } + } + return outputCropped.toUri() + } + + private fun calculateSampleSize(inputStream: InputStream): Int { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds + } + BitmapFactory.decodeStream(inputStream, null, options) + val imageWidth = options.outWidth + val imageHeight = options.outHeight + var inSampleSize = 1 + + while (imageHeight / inSampleSize > screenHeight || imageWidth / inSampleSize > screenWidth) { + inSampleSize *= 2 + } + return inSampleSize + } + + private suspend fun openInputStream(uri: Uri): InputStream? { + return withContext(Dispatchers.IO) { + when (uri.scheme) { + SCHEME_CONTENT -> context.contentResolver.openInputStream(uri) + SCHEME_FILE -> FileInputStream(uri.path) + else -> null + } + } + } + + private suspend fun decodeBitmap(uri: Uri): Bitmap? { + return withContext(Dispatchers.IO) { + when (uri.scheme) { + SCHEME_CONTENT -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.isMutableRequired = true + } + } else { + MediaStore.Images.Media.getBitmap(context.contentResolver, uri) + } + SCHEME_FILE -> BitmapFactory.decodeFile(uri.path) + else -> null + } + } + } + + private fun deleteIfFile(uri: Uri) { + if (uri.scheme == SCHEME_FILE) { + uri.toFile().delete() + } + } + + private fun exists(uri: Uri): Boolean { + return when (uri.scheme) { + SCHEME_CONTENT -> { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + cursor.moveToFirst() + } ?: false + } + SCHEME_FILE -> uri.toFile().exists() + else -> false + } + } + + private suspend fun samplingBitmap(bitmap: Bitmap, sampleSize: Int): Bitmap { + return withContext(Dispatchers.IO) { + Bitmap.createScaledBitmap(bitmap, bitmap.width / sampleSize, bitmap.height / sampleSize, false) + } + } + + private suspend fun centerCropBitmap(bitmap: Bitmap, aspectRatio: Float): Bitmap { + return withContext(Dispatchers.IO) { + val bitmapAspectRatio = bitmap.width.toFloat() / bitmap.height + if (bitmapAspectRatio > aspectRatio) { + val newWidth = (bitmap.height * aspectRatio).toInt() + Bitmap.createBitmap(bitmap, (bitmap.width - newWidth) / 2, 0, newWidth, bitmap.height) + } else { + val newHeight = (bitmap.width / aspectRatio).toInt() + Bitmap.createBitmap(bitmap, 0, (bitmap.height - newHeight) / 2, bitmap.width, newHeight) + } + } + } + + private suspend fun saveBitmap(bitmap: Bitmap, format: CompressFormat, quality: Int, file: File) { + withContext(Dispatchers.IO) { + FileOutputStream(file).use { output -> + bitmap.compress(format, quality, output) + } + } + } + + companion object { + private const val ORIGIN_PREFIX = "origin" + private const val CROPPED_PREFIX = "cropped" + + private const val QUALITY = 70 + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonLocalDataSource.kt b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonLocalDataSource.kt new file mode 100644 index 000000000..83e3ca03c --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonLocalDataSource.kt @@ -0,0 +1,37 @@ +package com.lighthouse.datasource.gifticon + +import com.lighthouse.database.entity.GifticonEntity +import com.lighthouse.database.entity.GifticonWithCrop +import com.lighthouse.domain.model.Brand +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.model.SortBy +import com.lighthouse.domain.model.UsageHistory +import kotlinx.coroutines.flow.Flow + +interface GifticonLocalDataSource { + + fun getGifticon(id: String): Flow + fun getAllGifticons(userId: String, sortBy: SortBy = SortBy.DEADLINE): Flow> + fun getAllUsedGifticons(userId: String): Flow> + fun getFilteredGifticons( + userId: String, + filter: Set, + sortBy: SortBy = SortBy.DEADLINE + ): Flow> + + fun getAllBrands(userId: String, filterExpired: Boolean): Flow> + suspend fun getGifticonCrop(userId: String, gifticonId: String): GifticonWithCrop? + suspend fun insertGifticons(gifticons: List) + suspend fun updateGifticon(gifticonWithCrop: GifticonWithCrop) + suspend fun useGifticon(gifticonId: String, usageHistory: UsageHistory) + suspend fun useCashCardGifticon(gifticonId: String, amount: Int, usageHistory: UsageHistory) + suspend fun unUseGifticon(gifticonId: String) + suspend fun removeGifticon(gifticonId: String) + fun getUsageHistory(gifticonId: String): Flow> + suspend fun insertUsageHistory(gifticonId: String, usageHistory: UsageHistory) + fun getGifticonByBrand(brand: String): Flow> + fun hasUsableGifticon(userId: String): Flow + fun getUsableGifticons(userId: String): Flow> + suspend fun hasGifticonBrand(brand: String): Boolean + suspend fun moveUserIdGifticon(oldUserId: String, newUserId: String) +} diff --git a/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonLocalDataSourceImpl.kt b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonLocalDataSourceImpl.kt new file mode 100644 index 000000000..2b737dccd --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/gifticon/GifticonLocalDataSourceImpl.kt @@ -0,0 +1,131 @@ +package com.lighthouse.datasource.gifticon + +import com.lighthouse.database.dao.GifticonDao +import com.lighthouse.database.entity.GifticonEntity +import com.lighthouse.database.entity.GifticonWithCrop +import com.lighthouse.database.mapper.toUsageHistory +import com.lighthouse.database.mapper.toUsageHistoryEntity +import com.lighthouse.domain.model.Brand +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.model.SortBy +import com.lighthouse.domain.model.UsageHistory +import com.lighthouse.domain.util.isExpired +import com.lighthouse.domain.util.today +import com.lighthouse.mapper.toDomain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class GifticonLocalDataSourceImpl @Inject constructor( + private val gifticonDao: GifticonDao +) : GifticonLocalDataSource { + + override fun getGifticon(id: String): Flow { + return gifticonDao.getGifticon(id).map { entity -> + entity.toDomain() + } + } + + override fun getAllGifticons(userId: String, sortBy: SortBy): Flow> { + val gifticons = when (sortBy) { + SortBy.DEADLINE -> gifticonDao.getAllGifticonsSortByDeadline(userId) + SortBy.RECENT -> gifticonDao.getAllGifticons(userId) + } + return gifticons.map { list -> + list.map { it.toDomain() } + } + } + + override fun getAllUsedGifticons(userId: String): Flow> { + return gifticonDao.getAllUsedGifticons(userId).map { list -> + list.map { it.toDomain() } + } + } + + override fun getFilteredGifticons(userId: String, filter: Set, sortBy: SortBy): Flow> { + val upperFilter = filter.map { it.uppercase() }.toSet() + val gifticons = when (sortBy) { + SortBy.DEADLINE -> gifticonDao.getFilteredGifticonsSortByDeadline(userId, upperFilter) + SortBy.RECENT -> gifticonDao.getFilteredGifticons(userId, upperFilter) + } + return gifticons.map { list -> + list.map { it.toDomain() } + } + } + + override fun getAllBrands(userId: String, filterExpired: Boolean): Flow> { + return gifticonDao.getAllGifticons(userId).map { + if (filterExpired) { + it.filterNot { entity -> + entity.expireAt.isExpired() + } + } else { + it + }.groupBy { entity -> + entity.brand.uppercase() + }.map { entry -> + Brand(entry.key.uppercase(), entry.value.size) + } + } + } + + override suspend fun getGifticonCrop(userId: String, gifticonId: String): GifticonWithCrop? { + return gifticonDao.getGifticonWithCrop(userId, gifticonId) + } + + override suspend fun updateGifticon(gifticonWithCrop: GifticonWithCrop) { + gifticonDao.updateGifticonWithCropTransaction(gifticonWithCrop) + } + + override suspend fun insertGifticons(gifticons: List) { + gifticonDao.insertGifticonWithCropTransaction(gifticons) + } + + override suspend fun useGifticon(gifticonId: String, usageHistory: UsageHistory) { + gifticonDao.useGifticonTransaction(usageHistory.toUsageHistoryEntity(gifticonId)) + } + + override suspend fun useCashCardGifticon(gifticonId: String, amount: Int, usageHistory: UsageHistory) { + gifticonDao.useCashCardGifticonTransaction(amount, usageHistory.toUsageHistoryEntity(gifticonId)) + } + + override suspend fun unUseGifticon(gifticonId: String) { + gifticonDao.unUseGifticon(gifticonId) + } + + override suspend fun removeGifticon(gifticonId: String) { + gifticonDao.removeGifticon(gifticonId) + } + + override fun getUsageHistory(gifticonId: String): Flow> { + return gifticonDao.getUsageHistory(gifticonId).map { list -> + list.map { entity -> + entity.toUsageHistory() + } + } + } + + override suspend fun insertUsageHistory(gifticonId: String, usageHistory: UsageHistory) { + gifticonDao.insertUsageHistory(usageHistory.toUsageHistoryEntity(gifticonId)) + } + + override fun getGifticonByBrand(brand: String): Flow> { + return gifticonDao.getGifticonByBrand(brand) + } + + override fun hasUsableGifticon(userId: String): Flow { + return gifticonDao.hasUsableGifticon(userId, today) + } + + override fun getUsableGifticons(userId: String): Flow> { + return gifticonDao.getAllUsableGifticons(userId, today) + } + + override suspend fun hasGifticonBrand(brand: String): Boolean { + return gifticonDao.hasGifticonBrand(brand) + } + + override suspend fun moveUserIdGifticon(oldUserId: String, newUserId: String) { + gifticonDao.moveUserIdGifticon(oldUserId, newUserId) + } +} diff --git a/data/src/main/java/com/lighthouse/datasource/location/SharedLocationManager.kt b/data/src/main/java/com/lighthouse/datasource/location/SharedLocationManager.kt new file mode 100644 index 000000000..ee4069e42 --- /dev/null +++ b/data/src/main/java/com/lighthouse/datasource/location/SharedLocationManager.kt @@ -0,0 +1,57 @@ +package com.lighthouse.datasource.location + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Looper +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.lighthouse.domain.VertexLocation +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject + +@SuppressLint("MissingPermission") +class SharedLocationManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) + + private val locationRequest = LocationRequest.create().apply { + interval = LOCATION_INTERVAL + fastestInterval = LOCATION_INTERVAL / 2 + priority = Priority.PRIORITY_HIGH_ACCURACY + maxWaitTime = WAITE_TIME + } + + private val locationUpdates = callbackFlow { + val locationCallback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + val locationResult = result.lastLocation ?: return + trySend(VertexLocation(locationResult.longitude, locationResult.latitude)) + } + } + + fusedLocationProviderClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ).addOnFailureListener { + fusedLocationProviderClient.removeLocationUpdates(locationCallback) + } + + awaitClose { + fusedLocationProviderClient.removeLocationUpdates(locationCallback) + } + } + + fun locationFlow() = locationUpdates + + companion object { + private const val LOCATION_INTERVAL = 30000L + private const val WAITE_TIME = 2000L + } +} diff --git a/data/src/main/java/com/lighthouse/mapper/BrandEntityMapper.kt b/data/src/main/java/com/lighthouse/mapper/BrandEntityMapper.kt new file mode 100644 index 000000000..87211efad --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/BrandEntityMapper.kt @@ -0,0 +1,37 @@ +package com.lighthouse.mapper + +import com.lighthouse.database.entity.BrandLocationEntity +import com.lighthouse.datasource.brand.BrandLocalDataSourceImpl +import com.lighthouse.domain.LocationConverter +import com.lighthouse.domain.model.BrandPlaceInfo + +fun List.toDomain(): List = this.map { brandLocationEntity -> + BrandPlaceInfo( + addressName = brandLocationEntity.addressName, + placeName = brandLocationEntity.placeName, + placeUrl = brandLocationEntity.placeUrl, + categoryName = brandLocationEntity.categoryName, + brand = brandLocationEntity.brand, + x = brandLocationEntity.x, + y = brandLocationEntity.y + ) +} + +fun List.toEntity(): List { + return this.map { brandPlaceInfo -> + val x = LocationConverter.toMinDms(brandPlaceInfo.x.toDouble()) + val y = LocationConverter.toMinDms(brandPlaceInfo.y.toDouble()) + val id = BrandLocalDataSourceImpl.combineSectionId(x.dmsToString(), y.dmsToString(), brandPlaceInfo.brand) + + BrandLocationEntity( + sectionId = id, + addressName = brandPlaceInfo.addressName, + placeName = brandPlaceInfo.placeName, + categoryName = brandPlaceInfo.categoryName, + placeUrl = brandPlaceInfo.placeUrl, + brand = brandPlaceInfo.brand, + x = brandPlaceInfo.x, + y = brandPlaceInfo.y + ) + } +} diff --git a/data/src/main/java/com/lighthouse/mapper/BrandPlaceInfoDataContainerMapper.kt b/data/src/main/java/com/lighthouse/mapper/BrandPlaceInfoDataContainerMapper.kt new file mode 100644 index 000000000..f4d5b2ac8 --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/BrandPlaceInfoDataContainerMapper.kt @@ -0,0 +1,18 @@ +package com.lighthouse.mapper + +import com.lighthouse.domain.model.BrandPlaceInfo +import com.lighthouse.model.BrandPlaceInfoDataContainer + +internal fun List.toDomain(brandName: String): List { + return this.map { + BrandPlaceInfo( + addressName = it.addressName, + placeName = it.placeName, + categoryName = it.categoryGroupName, + placeUrl = it.placeUrl, + brand = brandName, + x = it.x, + y = it.y + ) + } +} diff --git a/data/src/main/java/com/lighthouse/mapper/ErrorMapper.kt b/data/src/main/java/com/lighthouse/mapper/ErrorMapper.kt new file mode 100644 index 000000000..92fd8ebf5 --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/ErrorMapper.kt @@ -0,0 +1,8 @@ +package com.lighthouse.mapper + +import com.lighthouse.domain.model.BeepError +import com.lighthouse.model.BeepErrorData + +internal fun BeepErrorData.toDomain(): BeepError = when (this) { + is BeepErrorData.NetworkFailure -> BeepError.NetworkFailure +} diff --git a/data/src/main/java/com/lighthouse/mapper/GifticonCropMapper.kt b/data/src/main/java/com/lighthouse/mapper/GifticonCropMapper.kt new file mode 100644 index 000000000..62a35d9f5 --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/GifticonCropMapper.kt @@ -0,0 +1,11 @@ +package com.lighthouse.mapper + +import com.lighthouse.database.entity.GifticonCropEntity +import com.lighthouse.domain.model.GifticonCrop + +fun GifticonCrop.toEntity(): GifticonCropEntity { + return GifticonCropEntity( + gifticonId = gifticonId, + croppedRect = rect.toEntity() + ) +} diff --git a/data/src/main/java/com/lighthouse/mapper/GifticonEntityMapper.kt b/data/src/main/java/com/lighthouse/mapper/GifticonEntityMapper.kt new file mode 100644 index 000000000..9b16e62a3 --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/GifticonEntityMapper.kt @@ -0,0 +1,22 @@ +package com.lighthouse.mapper + +import com.lighthouse.database.entity.GifticonEntity +import com.lighthouse.domain.model.Gifticon + +fun GifticonEntity.toDomain(): Gifticon { + return Gifticon( + id = id, + createdAt = createdAt, + userId = userId, + hasImage = hasImage, + croppedUri = croppedUri.toString(), + name = name, + brand = brand, + expireAt = expireAt, + barcode = barcode, + isCashCard = isCashCard, + balance = balance, + memo = memo, + isUsed = isUsed + ) +} diff --git a/data/src/main/java/com/lighthouse/mapper/GifticonRecognizeInfoMapper.kt b/data/src/main/java/com/lighthouse/mapper/GifticonRecognizeInfoMapper.kt new file mode 100644 index 000000000..5e639dc05 --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/GifticonRecognizeInfoMapper.kt @@ -0,0 +1,21 @@ +package com.lighthouse.mapper + +import android.net.Uri +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.util.recognizer.GifticonRecognizeInfo + +fun GifticonRecognizeInfo.toDomain(originUri: Uri, croppedUri: Uri?): GifticonForAddition { + return GifticonForAddition( + true, + name, + brand, + barcode, + expiredAt, + isCashCard, + balance, + originUri.toString(), + croppedUri?.toString() ?: "", + croppedRect.toDomain(), + "" + ) +} diff --git a/data/src/main/java/com/lighthouse/mapper/RectMapper.kt b/data/src/main/java/com/lighthouse/mapper/RectMapper.kt new file mode 100644 index 000000000..1d8e327ff --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/RectMapper.kt @@ -0,0 +1,8 @@ +package com.lighthouse.mapper + +import android.graphics.Rect +import com.lighthouse.domain.model.Rectangle + +fun Rect.toDomain(): Rectangle { + return Rectangle(left, top, right, bottom) +} diff --git a/data/src/main/java/com/lighthouse/mapper/RectangleMapper.kt b/data/src/main/java/com/lighthouse/mapper/RectangleMapper.kt new file mode 100644 index 000000000..c1ae1ffbd --- /dev/null +++ b/data/src/main/java/com/lighthouse/mapper/RectangleMapper.kt @@ -0,0 +1,8 @@ +package com.lighthouse.mapper + +import android.graphics.Rect +import com.lighthouse.domain.model.Rectangle + +fun Rectangle.toEntity(): Rect { + return Rect(left, top, right, bottom) +} diff --git a/data/src/main/java/com/lighthouse/model/BeepErrorData.kt b/data/src/main/java/com/lighthouse/model/BeepErrorData.kt new file mode 100644 index 000000000..16d876e89 --- /dev/null +++ b/data/src/main/java/com/lighthouse/model/BeepErrorData.kt @@ -0,0 +1,9 @@ +package com.lighthouse.model + +sealed class BeepErrorData( + override val message: String? = null, + override val cause: Throwable? = null +) : Exception(message, cause) { + + object NetworkFailure : BeepErrorData() +} diff --git a/data/src/main/java/com/lighthouse/model/BrandPlaceInfoDataContainer.kt b/data/src/main/java/com/lighthouse/model/BrandPlaceInfoDataContainer.kt new file mode 100644 index 000000000..b08db0f26 --- /dev/null +++ b/data/src/main/java/com/lighthouse/model/BrandPlaceInfoDataContainer.kt @@ -0,0 +1,41 @@ +package com.lighthouse.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class BrandPlaceInfoDataContainer( + val documents: List, + val meta: Meta +) { + @JsonClass(generateAdapter = true) + data class BrandPlaceInfoData( + @field:Json(name = "address_name") val addressName: String, + @field:Json(name = "category_group_code") val categoryGroupCode: String, + @field:Json(name = "category_group_name") val categoryGroupName: String, + @field:Json(name = "category_name") val categoryName: String, + @field:Json(name = "distance") val distance: String, + @field:Json(name = "id") val id: String, + @field:Json(name = "phone") val phone: String, + @field:Json(name = "place_name") val placeName: String, + @field:Json(name = "place_url") val placeUrl: String, + @field:Json(name = "road_address_name") val roadAddressName: String, + @field:Json(name = "x") val x: String, + @field:Json(name = "y") val y: String + ) + + @JsonClass(generateAdapter = true) + data class Meta( + @field:Json(name = "is_end") val isEnd: Boolean, + @field:Json(name = "pageable_count") val pageableCount: Int, + @field:Json(name = "same_name") val sameName: SameName, + @field:Json(name = "total_count") val totalCount: Int + ) + + @JsonClass(generateAdapter = true) + data class SameName( + @field:Json(name = "keyword") val keyword: String, + @field:Json(name = "region") val region: List, + @field:Json(name = "selected_region") val selectedRegion: String + ) +} diff --git a/data/src/main/java/com/lighthouse/model/GifticonImageResult.kt b/data/src/main/java/com/lighthouse/model/GifticonImageResult.kt new file mode 100644 index 000000000..6a4d08b6d --- /dev/null +++ b/data/src/main/java/com/lighthouse/model/GifticonImageResult.kt @@ -0,0 +1,8 @@ +package com.lighthouse.model + +import android.net.Uri + +data class GifticonImageResult( + val sampleSize: Int, + val outputCroppedUri: Uri +) diff --git a/data/src/main/java/com/lighthouse/network/NetworkApiService.kt b/data/src/main/java/com/lighthouse/network/NetworkApiService.kt new file mode 100644 index 000000000..e64e179fc --- /dev/null +++ b/data/src/main/java/com/lighthouse/network/NetworkApiService.kt @@ -0,0 +1,20 @@ +package com.lighthouse.network + +import com.lighthouse.model.BrandPlaceInfoDataContainer +import retrofit2.http.GET +import retrofit2.http.Query + +interface NetworkApiService { + + @GET("v2/local/search/keyword.json") + suspend fun getAllBrandPlaceInfo( + @Query("query") query: String, + @Query("rect") rect: String, + @Query("size") size: Int, + @Query("category_group_code") groupCode: String = CATEGORY_GROUP_CODE + ): BrandPlaceInfoDataContainer + + companion object { + private const val CATEGORY_GROUP_CODE = "MT1,CS2,CT1,AD5,FD6,CE7" + } +} diff --git a/data/src/main/java/com/lighthouse/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/AuthRepositoryImpl.kt new file mode 100644 index 000000000..e6586b2d8 --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/AuthRepositoryImpl.kt @@ -0,0 +1,14 @@ +package com.lighthouse.repository + +import com.lighthouse.datasource.auth.AuthDataSource +import com.lighthouse.domain.repository.AuthRepository +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val authDataSource: AuthDataSource +) : AuthRepository { + + override fun getCurrentUserId(): String { + return authDataSource.getCurrentUserId() + } +} diff --git a/data/src/main/java/com/lighthouse/repository/BrandRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/BrandRepositoryImpl.kt new file mode 100644 index 000000000..768d4c039 --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/BrandRepositoryImpl.kt @@ -0,0 +1,43 @@ +package com.lighthouse.repository + +import com.lighthouse.datasource.brand.BrandLocalDataSource +import com.lighthouse.datasource.brand.BrandRemoteDataSource +import com.lighthouse.domain.Dms +import com.lighthouse.domain.model.BrandPlaceInfo +import com.lighthouse.domain.repository.BrandRepository +import com.lighthouse.mapper.toDomain +import com.lighthouse.model.BeepErrorData +import javax.inject.Inject + +class BrandRepositoryImpl @Inject constructor( + private val brandRemoteSource: BrandRemoteDataSource, + private val brandLocalSource: BrandLocalDataSource +) : BrandRepository { + + override suspend fun getBrandPlaceInfo( + brandName: String, + x: Dms, + y: Dms, + size: Int + ): Result> = brandLocalSource.getBrands(x, y, brandName).mapCatching { it.toDomain() } + .recoverCatching { + getRemoteSourceData(brandName, x, y, size).getOrDefault(emptyList()) + brandLocalSource.getBrands(x, y, brandName).mapCatching { it.toDomain() }.getOrDefault(emptyList()) + } + + private suspend fun getRemoteSourceData( + brandName: String, + x: Dms, + y: Dms, + size: Int + ): Result> { + val result = brandRemoteSource.getBrandPlaceInfo(brandName, x, y, size).mapCatching { it.toDomain(brandName) } + val exception = result.exceptionOrNull() + + return if (exception is BeepErrorData) { + Result.failure(exception.toDomain()) + } else { + result.onSuccess { brandLocalSource.insertBrands(it, x, y, brandName) } + } + } +} diff --git a/data/src/main/java/com/lighthouse/repository/GalleryImageRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/GalleryImageRepositoryImpl.kt new file mode 100644 index 000000000..4dc42c78a --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/GalleryImageRepositoryImpl.kt @@ -0,0 +1,25 @@ +package com.lighthouse.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.lighthouse.datasource.gallery.GalleryImageLocalSource +import com.lighthouse.datasource.gallery.GalleryImagePagingSource +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.domain.repository.GalleryImageRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GalleryImageRepositoryImpl @Inject constructor( + private val localSource: GalleryImageLocalSource +) : GalleryImageRepository { + + override fun getImages(pageSize: Int): Flow> { + return Pager( + config = PagingConfig(pageSize = pageSize, enablePlaceholders = false), + pagingSourceFactory = { + GalleryImagePagingSource(localSource, 0, pageSize) + } + ).flow + } +} diff --git a/data/src/main/java/com/lighthouse/repository/GifticonImageRecognizeRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/GifticonImageRecognizeRepositoryImpl.kt new file mode 100644 index 000000000..ff2c7bc3b --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/GifticonImageRecognizeRepositoryImpl.kt @@ -0,0 +1,38 @@ +package com.lighthouse.repository + +import android.net.Uri +import com.lighthouse.datasource.gifticon.GifticonImageRecognizeSource +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import java.util.Date +import javax.inject.Inject + +class GifticonImageRecognizeRepositoryImpl @Inject constructor( + private val gifticonImageRecognizeSource: GifticonImageRecognizeSource +) : GifticonImageRecognizeRepository { + + override suspend fun recognize(gallery: GalleryImage): GifticonForAddition? { + return gifticonImageRecognizeSource.recognize(gallery.id, Uri.parse(gallery.contentUri)) + } + + override suspend fun recognizeGifticonName(path: String): String { + return gifticonImageRecognizeSource.recognizeGifticonName(Uri.parse(path)) + } + + override suspend fun recognizeBrandName(path: String): String { + return gifticonImageRecognizeSource.recognizeBrandName(Uri.parse(path)) + } + + override suspend fun recognizeBarcode(path: String): String { + return gifticonImageRecognizeSource.recognizeBarcode(Uri.parse(path)) + } + + override suspend fun recognizeBalance(path: String): Int { + return gifticonImageRecognizeSource.recognizeBalance(Uri.parse(path)) + } + + override suspend fun recognizeExpired(path: String): Date { + return gifticonImageRecognizeSource.recognizeExpired(Uri.parse(path)) + } +} diff --git a/data/src/main/java/com/lighthouse/repository/GifticonRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/GifticonRepositoryImpl.kt new file mode 100644 index 000000000..ddcdcaf24 --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/GifticonRepositoryImpl.kt @@ -0,0 +1,176 @@ +package com.lighthouse.repository + +import android.net.Uri +import com.lighthouse.database.mapper.toDomain +import com.lighthouse.database.mapper.toEntity +import com.lighthouse.datasource.gifticon.GifticonImageSource +import com.lighthouse.datasource.gifticon.GifticonLocalDataSource +import com.lighthouse.domain.model.Brand +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.domain.model.SortBy +import com.lighthouse.domain.model.UsageHistory +import com.lighthouse.domain.repository.GifticonRepository +import com.lighthouse.mapper.toDomain +import com.lighthouse.model.GifticonImageResult +import com.lighthouse.util.UUID +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class GifticonRepositoryImpl @Inject constructor( + private val gifticonLocalDataSource: GifticonLocalDataSource, + private val gifticonImageSource: GifticonImageSource +) : GifticonRepository { + + override fun getGifticon(id: String): Flow> = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getGifticon(id).collect { + emit(DbResult.Success(it)) + } + }.catch { e -> + emit(DbResult.Failure(e)) + } + + override fun getAllGifticons(userId: String, sortBy: SortBy): Flow>> = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getAllGifticons(userId, sortBy).collect { + emit(DbResult.Success(it)) + } + }.catch { e -> + emit(DbResult.Failure(e)) + } + + override fun getAllUsedGifticons(userId: String): Flow>> = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getAllUsedGifticons(userId).collect { + emit(DbResult.Success(it)) + } + }.catch { e -> + emit(DbResult.Failure(e)) + } + + override fun getFilteredGifticons( + userId: String, + filter: Set, + sortBy: SortBy + ): Flow>> = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getFilteredGifticons(userId, filter, sortBy).collect { + emit(DbResult.Success(it)) + } + }.catch { e -> + emit(DbResult.Failure(e)) + } + + override fun getAllBrands(userId: String, filterExpired: Boolean): Flow>> = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getAllBrands(userId, filterExpired).collect { + emit(DbResult.Success(it)) + } + }.catch { e -> + emit(DbResult.Failure(e)) + } + + override suspend fun getGifticonCrop(userId: String, id: String): GifticonForUpdate? { + return gifticonLocalDataSource.getGifticonCrop(userId, id)?.toDomain() + } + + override suspend fun updateGifticon(gifticonForUpdate: GifticonForUpdate) { + var croppedUri: Uri? = null + if (gifticonForUpdate.isUpdatedImage) { + croppedUri = gifticonImageSource.updateImage( + gifticonForUpdate.id, + Uri.parse(gifticonForUpdate.oldCroppedUri), + Uri.parse(gifticonForUpdate.croppedUri) + ) + } + gifticonLocalDataSource.updateGifticon(gifticonForUpdate.toEntity(croppedUri)) + } + + override suspend fun saveGifticons(userId: String, gifticonForAdditions: List) { + val newGifticons = gifticonForAdditions.map { gifticonForAddition -> + val id = UUID.generate() + var result: GifticonImageResult? = null + if (gifticonForAddition.hasImage) { + result = gifticonImageSource.saveImage( + id, + Uri.parse(gifticonForAddition.originUri), + Uri.parse(gifticonForAddition.tempCroppedUri) + ) + } + gifticonForAddition.toEntity(id, userId, result) + } + gifticonLocalDataSource.insertGifticons(newGifticons) + } + + override fun getUsageHistory(gifticonId: String): Flow>> = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getUsageHistory(gifticonId).collect { + if (it.isEmpty()) { + emit(DbResult.Empty) + } else { + emit(DbResult.Success(it)) + } + } + }.catch { e -> + emit(DbResult.Failure(e)) + } + + override suspend fun saveUsageHistory(gifticonId: String, usageHistory: UsageHistory) { + gifticonLocalDataSource.insertUsageHistory(gifticonId, usageHistory) + } + + override suspend fun useGifticon(gifticonId: String, usageHistory: UsageHistory) { + gifticonLocalDataSource.useGifticon(gifticonId, usageHistory) + } + + override suspend fun useCashCardGifticon(gifticonId: String, amount: Int, usageHistory: UsageHistory) { + gifticonLocalDataSource.useCashCardGifticon(gifticonId, amount, usageHistory) + } + + override suspend fun unUseGifticon(gifticonId: String) { + gifticonLocalDataSource.unUseGifticon(gifticonId) + } + + override suspend fun removeGifticon(gifticonId: String) { + gifticonLocalDataSource.removeGifticon(gifticonId) + } + + override fun getGifticonByBrand(brand: String) = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getGifticonByBrand(brand).collect { gifticons -> + if (gifticons.isEmpty()) { + emit(DbResult.Empty) + } else { + emit(DbResult.Success(gifticons.map { it.toDomain() })) + } + } + } + + override fun hasUsableGifticon(userId: String) = flow { + gifticonLocalDataSource.hasUsableGifticon(userId).collect { hasUsableGifticon -> + emit(hasUsableGifticon) + } + } + + override fun getUsableGifticons(userId: String) = flow { + emit(DbResult.Loading) + gifticonLocalDataSource.getUsableGifticons(userId).collect { gifticons -> + if (gifticons.isEmpty()) { + emit(DbResult.Empty) + } else { + emit(DbResult.Success(gifticons.map { it.toDomain() })) + } + } + } + + override suspend fun hasGifticonBrand(brand: String) = gifticonLocalDataSource.hasGifticonBrand(brand) + + override suspend fun moveUserIdGifticon(oldUserId: String, newUserId: String) { + gifticonLocalDataSource.moveUserIdGifticon(oldUserId, newUserId) + } +} diff --git a/data/src/main/java/com/lighthouse/repository/LocationRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/LocationRepositoryImpl.kt new file mode 100644 index 000000000..fae70b1f7 --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/LocationRepositoryImpl.kt @@ -0,0 +1,12 @@ +package com.lighthouse.repository + +import com.lighthouse.datasource.location.SharedLocationManager +import com.lighthouse.domain.repository.LocationRepository +import javax.inject.Inject + +class LocationRepositoryImpl @Inject constructor( + private val sharedLocationManager: SharedLocationManager +) : LocationRepository { + + override fun getLocations() = sharedLocationManager.locationFlow() +} diff --git a/data/src/main/java/com/lighthouse/repository/SecurityRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/SecurityRepositoryImpl.kt new file mode 100644 index 000000000..021863146 --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/SecurityRepositoryImpl.kt @@ -0,0 +1,11 @@ +package com.lighthouse.repository + +import com.lighthouse.domain.repository.SecurityRepository +import com.lighthouse.util.CryptoObjectHelper +import javax.crypto.Cipher + +class SecurityRepositoryImpl : SecurityRepository { + override fun getFingerprintCipher(): Cipher { + return CryptoObjectHelper.getFingerprintCipher() + } +} diff --git a/data/src/main/java/com/lighthouse/repository/UserPreferencesRepositoryImpl.kt b/data/src/main/java/com/lighthouse/repository/UserPreferencesRepositoryImpl.kt new file mode 100644 index 000000000..253facba9 --- /dev/null +++ b/data/src/main/java/com/lighthouse/repository/UserPreferencesRepositoryImpl.kt @@ -0,0 +1,129 @@ +package com.lighthouse.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.byteArrayPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.repository.UserPreferencesRepository +import com.lighthouse.util.CryptoObjectHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class UserPreferencesRepositoryImpl @Inject constructor( + private val dataStore: DataStore +) : UserPreferencesRepository { + private var uid = Firebase.auth.currentUser?.uid ?: "Guest" + private val guestKey = booleanPreferencesKey("guest") + private val pinKey get() = byteArrayPreferencesKey("${uid}pin") + private val ivKey get() = byteArrayPreferencesKey("${uid}iv") + private val securityKey get() = intPreferencesKey("${uid}security") + private val notificationKey get() = booleanPreferencesKey("${uid}notification") + + override fun isStored(option: UserPreferenceOption): Flow { + val key = when (option) { + UserPreferenceOption.SECURITY -> securityKey + UserPreferenceOption.NOTIFICATION -> notificationKey + UserPreferenceOption.GUEST -> guestKey + } + + return dataStore.data.map { preferences -> + preferences.contains(key) + } + } + + override suspend fun moveGuestData(uid: String): Result = runCatching { + val pin = dataStore.data.map { it[pinKey] }.first() + val iv = dataStore.data.map { it[ivKey] }.first() + val security = dataStore.data.map { it[securityKey] }.first() + val notification = dataStore.data.map { it[notificationKey] }.first() + + this.uid = uid + + withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + preferences[pinKey] = pin as ByteArray + preferences[ivKey] = iv as ByteArray + preferences[securityKey] = security as Int + preferences[notificationKey] = notification as Boolean + } + } + } + + override suspend fun removeCurrentUserData(): Result = runCatching { + dataStore.edit { preferences -> + preferences.remove(guestKey) + if (preferences.contains(pinKey)) { + preferences.remove(pinKey) + preferences.remove(ivKey) + } + preferences.remove(securityKey) + preferences.remove(notificationKey) + } + } + + override suspend fun setPinString(pinString: String): Result = runCatching { + withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + val cryptoResult = CryptoObjectHelper.encrypt(pinString) + preferences[pinKey] = cryptoResult.first + preferences[ivKey] = cryptoResult.second + } + } + } + + override fun getPinString(): Flow = dataStore.data.map { preferences -> + val pin = preferences[pinKey] ?: throw Exception("Cannot Find Encrypted PIN") + val iv = preferences[ivKey] ?: throw Exception("Cannot Find IV") + + CryptoObjectHelper.decrypt(pin, iv) + } + + override suspend fun setSecurityOption(value: Int): Result = runCatching { + withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + preferences[securityKey] = value + } + } + } + + override fun getSecurityOption(): Flow { + return dataStore.data.map { preferences -> + preferences[securityKey] ?: 0 + } + } + + override suspend fun setBooleanOption(option: UserPreferenceOption, value: Boolean): Result = runCatching { + val key = getPreferenceKey(option) + + withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + preferences[key] = value + } + } + } + + override fun getBooleanOption(option: UserPreferenceOption): Flow { + val key = getPreferenceKey(option) + + return dataStore.data.map { preferences -> + preferences[key] ?: false + } + } + + private fun getPreferenceKey(option: UserPreferenceOption): Preferences.Key { + return when (option) { + UserPreferenceOption.GUEST -> guestKey + UserPreferenceOption.NOTIFICATION -> notificationKey + else -> throw Exception("Unsupportable Option : Not Boolean") + } + } +} diff --git a/data/src/main/java/com/lighthouse/util/CryptoObjectHelper.kt b/data/src/main/java/com/lighthouse/util/CryptoObjectHelper.kt new file mode 100644 index 000000000..308730cd1 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/CryptoObjectHelper.kt @@ -0,0 +1,92 @@ +package com.lighthouse.util + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.Key +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.IvParameterSpec + +object CryptoObjectHelper { + + private const val KEY_NAME_FINGERPRINT = "fingerprint_authentication_key" + private const val KEY_NAME_PIN = "pin_authentication_key" + private const val KEYSTORE_NAME = "AndroidKeyStore" + + private const val KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC + private const val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val TRANSFORMATION = "$KEY_ALGORITHM/$BLOCK_MODE/$ENCRYPTION_PADDING" + + private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_NAME) + + init { + keyStore.load(null) + } + + fun getFingerprintCipher(): Cipher { + val key = getKey(KEY_NAME_FINGERPRINT) + return Cipher.getInstance(TRANSFORMATION).also { + it.init(Cipher.ENCRYPT_MODE, key) + } + } + + private fun getKey(keyName: String): Key { + if (keyStore.isKeyEntry(keyName).not()) { + createKey(keyName) + } + return keyStore.getKey(keyName, null) + } + + private fun createKey(keyName: String) { + val keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM, KEYSTORE_NAME) + val keyGenParameterSpec = when (keyName) { + KEY_NAME_FINGERPRINT -> { + KeyGenParameterSpec.Builder( + KEY_NAME_FINGERPRINT, + KeyProperties.PURPOSE_ENCRYPT + ).setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(ENCRYPTION_PADDING) + .setUserAuthenticationRequired(false) + .build() + } + KEY_NAME_PIN -> { + KeyGenParameterSpec.Builder( + KEY_NAME_PIN, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ).setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(ENCRYPTION_PADDING) + .setDigests(KeyProperties.DIGEST_SHA256) + .setUserAuthenticationRequired(false) + .build() + } + else -> return + } + keyGenerator.init(keyGenParameterSpec) + keyGenerator.generateKey() + } + + fun encrypt(pin: String): Pair { + if (keyStore.containsAlias(KEY_NAME_PIN).not()) { + createKey(KEY_NAME_PIN) + } + val secretKey = getKey(KEY_NAME_PIN) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + val encrypted = cipher.doFinal(pin.toByteArray(Charsets.UTF_8)) + val iv = cipher.iv + + return Pair(encrypted, iv) + } + + fun decrypt(encrypted: ByteArray, iv: ByteArray): String { + val secretKey = getKey(KEY_NAME_PIN) + val cipher = Cipher.getInstance(TRANSFORMATION) + + cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv)) + val decrypted = cipher.doFinal(encrypted) + return String(decrypted, Charsets.UTF_8) + } +} diff --git a/data/src/main/java/com/lighthouse/util/UUID.kt b/data/src/main/java/com/lighthouse/util/UUID.kt new file mode 100644 index 000000000..8954eeb4c --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/UUID.kt @@ -0,0 +1,7 @@ +package com.lighthouse.util + +import java.util.UUID + +object UUID { + fun generate() = UUID.randomUUID().toString() +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/BalanceRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/BalanceRecognizer.kt new file mode 100644 index 000000000..ea6efdc4a --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/BalanceRecognizer.kt @@ -0,0 +1,18 @@ +package com.lighthouse.util.recognizer + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.parser.BalanceParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class BalanceRecognizer { + + private val balanceParser = BalanceParser() + + private val textRecognizer = TextRecognizer() + + suspend fun recognize(bitmap: Bitmap) = withContext(Dispatchers.IO) { + val inputs = textRecognizer.recognize(bitmap) + balanceParser.parseCashCard(inputs) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/BarcodeRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/BarcodeRecognizer.kt new file mode 100644 index 000000000..cbab669ab --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/BarcodeRecognizer.kt @@ -0,0 +1,18 @@ +package com.lighthouse.util.recognizer + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.parser.BarcodeParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class BarcodeRecognizer { + + private val barcodeParser = BarcodeParser() + + private val textRecognizer = TextRecognizer() + + suspend fun recognize(bitmap: Bitmap) = withContext(Dispatchers.IO) { + val inputs = textRecognizer.recognize(bitmap) + barcodeParser.parseBarcode(inputs) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/ExpiredRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/ExpiredRecognizer.kt new file mode 100644 index 000000000..6ac6c0361 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/ExpiredRecognizer.kt @@ -0,0 +1,18 @@ +package com.lighthouse.util.recognizer + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.parser.ExpiredParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ExpiredRecognizer { + + private val expiredParser = ExpiredParser() + + private val textRecognizer = TextRecognizer() + + suspend fun recognize(bitmap: Bitmap) = withContext(Dispatchers.IO) { + val inputs = textRecognizer.recognize(bitmap) + expiredParser.parseExpiredDate(inputs) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/GifticonRecognizeInfo.kt b/data/src/main/java/com/lighthouse/util/recognizer/GifticonRecognizeInfo.kt new file mode 100644 index 000000000..09c5e1019 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/GifticonRecognizeInfo.kt @@ -0,0 +1,17 @@ +package com.lighthouse.util.recognizer + +import android.graphics.Bitmap +import android.graphics.Rect +import java.util.Date + +data class GifticonRecognizeInfo( + val name: String = "", + val brand: String = "", + val expiredAt: Date = Date(0), + val barcode: String = "", + val isCashCard: Boolean = false, + val balance: Int = 0, + val candidate: List = listOf(), + val croppedImage: Bitmap? = null, + val croppedRect: Rect = Rect() +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/GifticonRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/GifticonRecognizer.kt new file mode 100644 index 000000000..759698eec --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/GifticonRecognizer.kt @@ -0,0 +1,51 @@ +package com.lighthouse.util.recognizer + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.parser.BalanceParser +import com.lighthouse.util.recognizer.parser.BarcodeParser +import com.lighthouse.util.recognizer.parser.ExpiredParser +import com.lighthouse.util.recognizer.recognizer.TemplateRecognizer +import com.lighthouse.util.recognizer.recognizer.giftishow.GiftishowRecognizer +import com.lighthouse.util.recognizer.recognizer.kakao.KakaoRecognizer +import com.lighthouse.util.recognizer.recognizer.smilecon.SmileConRecognizer +import com.lighthouse.util.recognizer.recognizer.syrup.SyrupRecognizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class GifticonRecognizer { + private val barcodeParser = BarcodeParser() + private val expiredParser = ExpiredParser() + private val balanceParser = BalanceParser() + + private val textRecognizer = TextRecognizer() + + private val templateRecognizerList = listOf( + KakaoRecognizer(), + SyrupRecognizer(), + GiftishowRecognizer(), + SmileConRecognizer() + ) + + private fun getTemplateRecognizer(inputs: List): TemplateRecognizer? { + return templateRecognizerList.firstOrNull { + it.match(inputs) + } + } + + suspend fun recognize(bitmap: Bitmap): GifticonRecognizeInfo = withContext(Dispatchers.IO) { + val inputs = textRecognizer.recognize(bitmap) + val barcodeResult = barcodeParser.parseBarcode(inputs) + val expiredResult = expiredParser.parseExpiredDate(barcodeResult.filtered) + var info = GifticonRecognizeInfo(candidate = expiredResult.filtered) + getTemplateRecognizer(info.candidate)?.run { + info = recognize(bitmap, info.candidate) + } + val balanceResult = balanceParser.parseCashCard(info.candidate) + info.copy( + barcode = barcodeResult.barcode, + expiredAt = expiredResult.expired, + isCashCard = balanceResult.balance > 0, + balance = balanceResult.balance + ) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/TextRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/TextRecognizer.kt new file mode 100644 index 000000000..2b6a1a3e6 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/TextRecognizer.kt @@ -0,0 +1,25 @@ +package com.lighthouse.util.recognizer + +import android.graphics.Bitmap +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +class TextRecognizer { + suspend fun recognize(bitmap: Bitmap) = withContext(Dispatchers.IO) { + callbackFlow { + val image = InputImage.fromBitmap(bitmap, 0) + val recognition = TextRecognition.getClient(KoreanTextRecognizerOptions.Builder().build()) + recognition.process(image).addOnSuccessListener { + trySend(it.text.lines().filter { line -> line != "" }) + close() + } + awaitClose() + }.first() + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/BalanceParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/BalanceParser.kt new file mode 100644 index 000000000..f1927f904 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/BalanceParser.kt @@ -0,0 +1,21 @@ +package com.lighthouse.util.recognizer.parser + +class BalanceParser { + private val cashCardFilterRegex = listOf( + CashCardRegex("\\b(\\d{0,3}[,]?\\d{0,3}[,]\\d{1,3})".toRegex(), 1), + CashCardRegex("(\\d+)천원".toRegex(), 1000), + CashCardRegex("(\\d+)만원".toRegex(), 10000), + CashCardRegex("(\\d+)십만원".toRegex(), 100000) + ) + + fun parseCashCard(inputs: List): BalanceParserResult { + val balance = inputs.firstNotNullOfOrNull { text -> + cashCardFilterRegex.firstNotNullOfOrNull { cashCard -> + val value = cashCard.regex.find(text)?.groupValues?.getOrNull(1) + value?.filter { it.isDigit() }?.toInt()?.times(cashCard.unit) + } + } ?: 0 + + return BalanceParserResult(balance = if (balance >= 100) balance else 0) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/BalanceParserResult.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/BalanceParserResult.kt new file mode 100644 index 000000000..569f43ab8 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/BalanceParserResult.kt @@ -0,0 +1,5 @@ +package com.lighthouse.util.recognizer.parser + +data class BalanceParserResult( + val balance: Int +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/BarcodeParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/BarcodeParser.kt new file mode 100644 index 000000000..abf1e2558 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/BarcodeParser.kt @@ -0,0 +1,34 @@ +package com.lighthouse.util.recognizer.parser + +class BarcodeParser { + private val barcodeFilterRegex = listOf( + "\\b(\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4})\\b".toRegex(), + "\\b(\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{2})\\b".toRegex(), + "\\b(\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4})\\b".toRegex(), + "\\b(\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{2})\\b".toRegex(), + "\\b(\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{4})\\b".toRegex(), + "\\b(\\d{4}[- ]+\\d{4}[- ]+\\d{4}[- ]+\\d{2})\\b".toRegex(), + "\\b(\\d{4}[- ]+\\d{4}[- ]+\\d{4})\\b".toRegex(), + "\\b(\\d{16})\\b".toRegex(), + "\\b(\\d{14})\\b".toRegex(), + "\\b(\\d{12})\\b".toRegex() + ) + + fun parseBarcode(inputs: List): BarcodeParserResult { + var barcode = "" + val barcodeFiltered = mutableListOf() + inputs.forEach { text -> + val find = barcodeFilterRegex.firstNotNullOfOrNull { regex -> + regex.find(text) + } + if (find == null) { + barcodeFiltered.add(text) + } else { + if (barcode == "") { + barcode = find.groupValues.getOrNull(1)?.filter { it.isDigit() } ?: "" + } + } + } + return BarcodeParserResult(barcode, barcodeFiltered) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/BarcodeParserResult.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/BarcodeParserResult.kt new file mode 100644 index 000000000..9304c1d30 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/BarcodeParserResult.kt @@ -0,0 +1,6 @@ +package com.lighthouse.util.recognizer.parser + +data class BarcodeParserResult( + val barcode: String, + val filtered: List +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/BaseParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/BaseParser.kt new file mode 100644 index 000000000..4b89f70a7 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/BaseParser.kt @@ -0,0 +1,64 @@ +package com.lighthouse.util.recognizer.parser + +import com.lighthouse.util.recognizer.GifticonRecognizeInfo +import com.lighthouse.util.recognizer.processor.GifticonProcessTextTag + +abstract class BaseParser { + + protected abstract val keywordText: List + + fun match(inputs: List) = keywordText.find { keyword -> + inputs.any { it.lowercase().contains(keyword) } + } != null + + private fun parseGifticonName(info: GifticonRecognizeInfo, inputs: List): GifticonRecognizeInfo { + val name = inputs.joinToString("") + return info.copy(name = name, candidate = info.candidate + listOf(name)) + } + + private fun parseBrandName(info: GifticonRecognizeInfo, inputs: List): GifticonRecognizeInfo { + val brandName = inputs.joinToString("") + return info.copy(brand = brandName, candidate = info.candidate + listOf(brandName)) + } + + private fun parseGifticonBrandName(info: GifticonRecognizeInfo, inputs: List): GifticonRecognizeInfo { + val gifticonInputs: List + val brandInputs: List + if (inputs.size > 1) { + gifticonInputs = inputs.subList(0, inputs.lastIndex) + brandInputs = listOf(inputs.last()) + } else { + gifticonInputs = inputs + brandInputs = listOf("") + } + val newInfo = parseGifticonName(info, gifticonInputs) + return parseBrandName(newInfo, brandInputs) + } + + private fun parseBrandGifticonName(info: GifticonRecognizeInfo, inputs: List): GifticonRecognizeInfo { + val gifticonInputs: List + val brandInputs: List + if (inputs.size > 1) { + gifticonInputs = inputs.subList(1, inputs.size) + brandInputs = listOf(inputs.first()) + } else { + gifticonInputs = inputs + brandInputs = listOf("") + } + val newInfo = parseGifticonName(info, gifticonInputs) + return parseBrandName(newInfo, brandInputs) + } + + fun parseText( + info: GifticonRecognizeInfo, + tag: GifticonProcessTextTag, + inputs: List + ): GifticonRecognizeInfo { + return when (tag) { + GifticonProcessTextTag.GIFTICON_NAME -> parseGifticonName(info, inputs) + GifticonProcessTextTag.BRAND_NAME -> parseBrandName(info, inputs) + GifticonProcessTextTag.GIFTICON_BRAND_NAME -> parseGifticonBrandName(info, inputs) + GifticonProcessTextTag.BRAND_GIFTICON_NAME -> parseBrandGifticonName(info, inputs) + } + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/CashCardRegex.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/CashCardRegex.kt new file mode 100644 index 000000000..c9a568ba9 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/CashCardRegex.kt @@ -0,0 +1,6 @@ +package com.lighthouse.util.recognizer.parser + +data class CashCardRegex( + val regex: Regex, + val unit: Int +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/ExpiredParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/ExpiredParser.kt new file mode 100644 index 000000000..6b83f006c --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/ExpiredParser.kt @@ -0,0 +1,91 @@ +package com.lighthouse.util.recognizer.parser + +import java.util.Calendar +import java.util.Date +import java.util.LinkedList +import java.util.Locale +import java.util.Queue + +class ExpiredParser { + private val dateFilterRegex = listOf( + "(\\d{4})\\s*[-/년., ]+\\s*(\\d{1,2})\\s*[-/월., ]+\\s*(\\d{1,2})".toRegex(), + "\\b(\\d{4})(\\d{2})(\\d{2})\\b".toRegex() + ) + + private val expiredFilterRegex = listOf( + "만료[^\\d]*(\\d*)일".toRegex() + ) + + private fun parseDateFormat(inputs: List): ExpiredParserResult { + val dateList = mutableListOf() + val dateFiltered = mutableListOf() + + val queue: Queue = LinkedList().apply { + addAll(inputs) + } + + while (queue.isNotEmpty()) { + val text = queue.poll() ?: "" + val find = dateFilterRegex.firstNotNullOfOrNull { regex -> + regex.find(text) + } + + if (find == null) { + dateFiltered.add(text) + } else { + val year = find.groupValues.getOrNull(1)?.toInt() ?: continue + if (year !in 2000..2100) { + continue + } + val month = find.groupValues.getOrNull(2)?.toInt() ?: continue + if (month !in 1..12) { + continue + } + val dayOfMonth = find.groupValues.getOrNull(3)?.toInt() ?: continue + if (dayOfMonth !in 1..31) { + continue + } + val date = Calendar.getInstance(Locale.getDefault()).let { + it.set(year, month - 1, dayOfMonth) + it.time + } + dateList.add(date) + val end = find.groupValues.getOrNull(0)?.length + if (end != null && end < text.length) { + queue.add(text.substring(end, text.length)) + } + } + } + return ExpiredParserResult(dateList.maxOrNull() ?: Date(0), dateFiltered) + } + + private fun parseExpiredFormat(inputs: List): ExpiredParserResult { + val dateList = mutableListOf() + val dateFiltered = mutableListOf() + + inputs.forEach { text -> + val find = expiredFilterRegex.firstNotNullOfOrNull { regex -> + regex.find(text) + } + + if (find == null) { + dateFiltered.add(text) + } else { + val expiredDate = find.groupValues.getOrNull(1)?.toInt() ?: return@forEach + val date = Calendar.getInstance(Locale.getDefault()).let { + it.add(Calendar.DAY_OF_MONTH, expiredDate) + it.time + } + dateList.add(date) + } + } + return ExpiredParserResult(dateList.maxOrNull() ?: Date(0), dateFiltered) + } + + fun parseExpiredDate(inputs: List): ExpiredParserResult { + val dateFormatResult = parseDateFormat(inputs) + val expiredFormatResult = parseExpiredFormat(dateFormatResult.filtered) + val expiredDate = dateFormatResult.expired.coerceAtLeast(expiredFormatResult.expired) + return ExpiredParserResult(expiredDate, expiredFormatResult.filtered) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/parser/ExpiredParserResult.kt b/data/src/main/java/com/lighthouse/util/recognizer/parser/ExpiredParserResult.kt new file mode 100644 index 000000000..f05eb8b71 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/parser/ExpiredParserResult.kt @@ -0,0 +1,8 @@ +package com.lighthouse.util.recognizer.parser + +import java.util.Date + +data class ExpiredParserResult( + val expired: Date, + val filtered: List +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/processor/BaseProcessor.kt b/data/src/main/java/com/lighthouse/util/recognizer/processor/BaseProcessor.kt new file mode 100644 index 000000000..1854a8c13 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/processor/BaseProcessor.kt @@ -0,0 +1,96 @@ +package com.lighthouse.util.recognizer.processor + +import android.graphics.Bitmap +import android.graphics.Rect +import kotlin.math.max + +abstract class BaseProcessor : ScaleProcessor() { + + open val enableCenterCrop = false + + open val centerCropAspectRatio = 1f + + private fun calculateRect( + bitmap: Bitmap, + leftPercent: Float, + topPercent: Float, + rightPercent: Float, + bottomPercent: Float + ): Rect { + return Rect( + (bitmap.width * leftPercent).toInt(), + (bitmap.height * topPercent).toInt(), + (bitmap.width * rightPercent).toInt(), + (bitmap.height * bottomPercent).toInt() + ) + } + + private fun cropBitmap(bitmap: Bitmap, rect: Rect): Bitmap { + return Bitmap.createBitmap(bitmap, rect.left, rect.top, rect.width(), rect.height()) + } + + protected fun cropGifticonImage( + bitmap: Bitmap, + leftPercent: Float, + topPercent: Float, + rightPercent: Float, + bottomPercent: Float + ): GifticonProcessImage { + val cropRect = calculateRect(bitmap, leftPercent, topPercent, rightPercent, bottomPercent) + if (cropRect.width() != cropRect.height()) { + cropRect.inset( + max((cropRect.width() - cropRect.height()) / 2, 0), + max((cropRect.height() - cropRect.width()) / 2, 0) + ) + } + return GifticonProcessImage(cropBitmap(bitmap, cropRect), cropRect) + } + + protected fun cropAndScaleTextImage( + tag: GifticonProcessTextTag, + bitmap: Bitmap, + leftPercent: Float, + topPercent: Float, + rightPercent: Float, + bottomPercent: Float + ): GifticonProcessText { + val cropRect = calculateRect(bitmap, leftPercent, topPercent, rightPercent, bottomPercent) + return GifticonProcessText(tag, cropBitmap(bitmap, cropRect)) + } + + protected abstract fun processTextImage(bitmap: Bitmap): List + + protected abstract fun processGifticonImage(bitmap: Bitmap): GifticonProcessImage + + private fun centerCropBitmap(bitmap: Bitmap): Bitmap { + val bitmapAspectRatio = bitmap.width.toFloat() / bitmap.height + return if (bitmapAspectRatio > centerCropAspectRatio) { + val newWidth = (bitmap.height * centerCropAspectRatio).toInt() + Bitmap.createBitmap(bitmap, (bitmap.width - newWidth) / 2, 0, newWidth, bitmap.height) + } else { + val newHeight = (bitmap.width / centerCropAspectRatio).toInt() + Bitmap.createBitmap(bitmap, 0, (bitmap.height - newHeight) / 2, bitmap.width, newHeight) + } + } + + private fun adjustCropRect(origin: Bitmap, cropped: Bitmap, image: GifticonProcessImage): GifticonProcessImage { + val offsetX = (origin.width - cropped.width) / 2 + val offsetY = (origin.height - cropped.height) / 2 + return image.copy( + rect = image.rect.apply { + offset(offsetX, offsetY) + } + ) + } + + fun process(bitmap: Bitmap): GifticonProcessResult { + val newBitmap = if (enableCenterCrop) centerCropBitmap(bitmap) else bitmap + val image = processGifticonImage(newBitmap) + val textList = processTextImage(newBitmap) + return if (enableCenterCrop) { + GifticonProcessResult(adjustCropRect(bitmap, newBitmap, image), textList) + } else { + GifticonProcessResult(image, textList) + } + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessImage.kt b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessImage.kt new file mode 100644 index 000000000..e50df6764 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessImage.kt @@ -0,0 +1,9 @@ +package com.lighthouse.util.recognizer.processor + +import android.graphics.Bitmap +import android.graphics.Rect + +data class GifticonProcessImage( + val bitmap: Bitmap, + val rect: Rect +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessResult.kt b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessResult.kt new file mode 100644 index 000000000..7b8e27ec1 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessResult.kt @@ -0,0 +1,6 @@ +package com.lighthouse.util.recognizer.processor + +class GifticonProcessResult( + val image: GifticonProcessImage, + val textList: List +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessText.kt b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessText.kt new file mode 100644 index 000000000..ad46dc95a --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessText.kt @@ -0,0 +1,8 @@ +package com.lighthouse.util.recognizer.processor + +import android.graphics.Bitmap + +data class GifticonProcessText( + val tag: GifticonProcessTextTag, + val bitmap: Bitmap +) diff --git a/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessTextTag.kt b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessTextTag.kt new file mode 100644 index 000000000..804891409 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/processor/GifticonProcessTextTag.kt @@ -0,0 +1,8 @@ +package com.lighthouse.util.recognizer.processor + +enum class GifticonProcessTextTag { + GIFTICON_NAME, + BRAND_NAME, + GIFTICON_BRAND_NAME, + BRAND_GIFTICON_NAME +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/processor/ScaleProcessor.kt b/data/src/main/java/com/lighthouse/util/recognizer/processor/ScaleProcessor.kt new file mode 100644 index 000000000..66a21c053 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/processor/ScaleProcessor.kt @@ -0,0 +1,28 @@ +package com.lighthouse.util.recognizer.processor + +import android.content.res.Resources +import android.graphics.Bitmap + +open class ScaleProcessor { + private val screenWidth by lazy { + Resources.getSystem().displayMetrics.widthPixels + } + private val screenHeight by lazy { + Resources.getSystem().displayMetrics.heightPixels + } + private val screenAspect by lazy { + screenWidth.toFloat() / screenHeight + } + + fun scaleProcess(bitmap: Bitmap): Bitmap { + val originWidth = bitmap.width + val originHeight = bitmap.height + val originAspect = originWidth.toFloat() / originHeight + + return if (originAspect > screenAspect) { + Bitmap.createScaledBitmap(bitmap, screenWidth, (screenWidth / originAspect).toInt(), false) + } else { + Bitmap.createScaledBitmap(bitmap, (screenHeight * originAspect).toInt(), screenHeight, false) + } + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/TemplateRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/TemplateRecognizer.kt new file mode 100644 index 000000000..6b86124c7 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/TemplateRecognizer.kt @@ -0,0 +1,29 @@ +package com.lighthouse.util.recognizer.recognizer + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.GifticonRecognizeInfo +import com.lighthouse.util.recognizer.TextRecognizer +import com.lighthouse.util.recognizer.parser.BaseParser +import com.lighthouse.util.recognizer.processor.BaseProcessor + +abstract class TemplateRecognizer { + + protected abstract val parser: BaseParser + + protected abstract val processor: BaseProcessor + + private val textRecognizer = TextRecognizer() + + fun match(inputs: List) = parser.match(inputs) + + suspend fun recognize(bitmap: Bitmap, inputs: List): GifticonRecognizeInfo { + val result = processor.process(bitmap) + var newInfo = GifticonRecognizeInfo(candidate = inputs) + newInfo = newInfo.copy(croppedImage = result.image.bitmap, croppedRect = result.image.rect) + for (text in result.textList) { + val newInputs = textRecognizer.recognize(text.bitmap) + newInfo = parser.parseText(newInfo, text.tag, newInputs) + } + return newInfo + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowParser.kt new file mode 100644 index 000000000..391c273fd --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowParser.kt @@ -0,0 +1,11 @@ +package com.lighthouse.util.recognizer.recognizer.giftishow + +import com.lighthouse.util.recognizer.parser.BaseParser + +class GiftishowParser : BaseParser() { + + override val keywordText = listOf( + "기프티쇼", + "giftishow" + ) +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowProcessor.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowProcessor.kt new file mode 100644 index 000000000..10e6e4c7a --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowProcessor.kt @@ -0,0 +1,20 @@ +package com.lighthouse.util.recognizer.recognizer.giftishow + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.processor.BaseProcessor +import com.lighthouse.util.recognizer.processor.GifticonProcessImage +import com.lighthouse.util.recognizer.processor.GifticonProcessText +import com.lighthouse.util.recognizer.processor.GifticonProcessTextTag + +class GiftishowProcessor : BaseProcessor() { + + override fun processTextImage(bitmap: Bitmap): List { + return listOf( + cropAndScaleTextImage(GifticonProcessTextTag.GIFTICON_BRAND_NAME, bitmap, 0.23f, 0.83f, 0.96f, 0.94f) + ) + } + + override fun processGifticonImage(bitmap: Bitmap): GifticonProcessImage { + return cropGifticonImage(bitmap, 0.1f, 0.04f, 0.43f, 0.37f) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowRecognizer.kt new file mode 100644 index 000000000..2f3f2a8c8 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/giftishow/GiftishowRecognizer.kt @@ -0,0 +1,10 @@ +package com.lighthouse.util.recognizer.recognizer.giftishow + +import com.lighthouse.util.recognizer.recognizer.TemplateRecognizer + +class GiftishowRecognizer : TemplateRecognizer() { + + override val parser = GiftishowParser() + + override val processor = GiftishowProcessor() +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoParser.kt new file mode 100644 index 000000000..c1b2d517a --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoParser.kt @@ -0,0 +1,10 @@ +package com.lighthouse.util.recognizer.recognizer.kakao + +import com.lighthouse.util.recognizer.parser.BaseParser + +class KakaoParser : BaseParser() { + + override val keywordText = listOf( + "kakaotalk" + ) +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoProcessor.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoProcessor.kt new file mode 100644 index 000000000..acf622fc5 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoProcessor.kt @@ -0,0 +1,24 @@ +package com.lighthouse.util.recognizer.recognizer.kakao + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.processor.BaseProcessor +import com.lighthouse.util.recognizer.processor.GifticonProcessImage +import com.lighthouse.util.recognizer.processor.GifticonProcessText +import com.lighthouse.util.recognizer.processor.GifticonProcessTextTag + +class KakaoProcessor : BaseProcessor() { + + override val enableCenterCrop = true + + override val centerCropAspectRatio = 0.48f + + override fun processTextImage(bitmap: Bitmap): List { + return listOf( + cropAndScaleTextImage(GifticonProcessTextTag.BRAND_GIFTICON_NAME, bitmap, 0f, 0.4f, 0.75f, 0.55f) + ) + } + + override fun processGifticonImage(bitmap: Bitmap): GifticonProcessImage { + return cropGifticonImage(bitmap, 0.13125f, 0.05282f, 0.86875f, 0.41951f) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoRecognizer.kt new file mode 100644 index 000000000..b48941262 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/kakao/KakaoRecognizer.kt @@ -0,0 +1,10 @@ +package com.lighthouse.util.recognizer.recognizer.kakao + +import com.lighthouse.util.recognizer.recognizer.TemplateRecognizer + +class KakaoRecognizer : TemplateRecognizer() { + + override val parser = KakaoParser() + + override val processor = KakaoProcessor() +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConParser.kt new file mode 100644 index 000000000..64b5ee30c --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConParser.kt @@ -0,0 +1,12 @@ +package com.lighthouse.util.recognizer.recognizer.smilecon + +import com.lighthouse.util.recognizer.parser.BaseParser + +class SmileConParser : BaseParser() { + + override val keywordText = listOf( + "스마일콘", + "오피스콘", + "smile" + ) +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConProcessor.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConProcessor.kt new file mode 100644 index 000000000..fec40d7bb --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConProcessor.kt @@ -0,0 +1,27 @@ +package com.lighthouse.util.recognizer.recognizer.smilecon + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.processor.BaseProcessor +import com.lighthouse.util.recognizer.processor.GifticonProcessImage +import com.lighthouse.util.recognizer.processor.GifticonProcessText +import com.lighthouse.util.recognizer.processor.GifticonProcessTextTag + +class SmileConProcessor : BaseProcessor() { + + override fun processTextImage(bitmap: Bitmap): List { + return listOf( + cropAndScaleTextImage( + GifticonProcessTextTag.BRAND_GIFTICON_NAME, + bitmap, + 0.19801f, + 0.45263f, + 0.99008f, + 0.56841f + ) + ) + } + + override fun processGifticonImage(bitmap: Bitmap): GifticonProcessImage { + return cropGifticonImage(bitmap, 0.02475f, 0.05263f, 0.47029f, 0.43157f) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConRecognizer.kt new file mode 100644 index 000000000..2b3500772 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/smilecon/SmileConRecognizer.kt @@ -0,0 +1,10 @@ +package com.lighthouse.util.recognizer.recognizer.smilecon + +import com.lighthouse.util.recognizer.recognizer.TemplateRecognizer + +class SmileConRecognizer : TemplateRecognizer() { + + override val parser = SmileConParser() + + override val processor = SmileConProcessor() +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupParser.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupParser.kt new file mode 100644 index 000000000..4e75bbeba --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupParser.kt @@ -0,0 +1,12 @@ +package com.lighthouse.util.recognizer.recognizer.syrup + +import com.lighthouse.util.recognizer.parser.BaseParser + +class SyrupParser : BaseParser() { + + override val keywordText = listOf( + "syrup", + "gifticon", + "기프티콘" + ) +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupProcessor.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupProcessor.kt new file mode 100644 index 000000000..e2a65fa82 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupProcessor.kt @@ -0,0 +1,21 @@ +package com.lighthouse.util.recognizer.recognizer.syrup + +import android.graphics.Bitmap +import com.lighthouse.util.recognizer.processor.BaseProcessor +import com.lighthouse.util.recognizer.processor.GifticonProcessImage +import com.lighthouse.util.recognizer.processor.GifticonProcessText +import com.lighthouse.util.recognizer.processor.GifticonProcessTextTag + +class SyrupProcessor : BaseProcessor() { + + override fun processTextImage(bitmap: Bitmap): List { + return listOf( + cropAndScaleTextImage(GifticonProcessTextTag.GIFTICON_NAME, bitmap, 0.375f, 0.4375f, 0.98437f, 0.52083f), + cropAndScaleTextImage(GifticonProcessTextTag.BRAND_NAME, bitmap, 0.5625f, 0.59375f, 0.98437f, 0.6625f) + ) + } + + override fun processGifticonImage(bitmap: Bitmap): GifticonProcessImage { + return cropGifticonImage(bitmap, 0.0625f, 0.4375f, 0.375f, 0.6458f) + } +} diff --git a/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupRecognizer.kt b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupRecognizer.kt new file mode 100644 index 000000000..744c95a44 --- /dev/null +++ b/data/src/main/java/com/lighthouse/util/recognizer/recognizer/syrup/SyrupRecognizer.kt @@ -0,0 +1,10 @@ +package com.lighthouse.util.recognizer.recognizer.syrup + +import com.lighthouse.util.recognizer.recognizer.TemplateRecognizer + +class SyrupRecognizer : TemplateRecognizer() { + + override val parser = SyrupParser() + + override val processor = SyrupProcessor() +} diff --git a/data/src/test/java/data/datasource/BrandLocalDataSourceTest.kt b/data/src/test/java/data/datasource/BrandLocalDataSourceTest.kt new file mode 100644 index 000000000..5eafe47bf --- /dev/null +++ b/data/src/test/java/data/datasource/BrandLocalDataSourceTest.kt @@ -0,0 +1,141 @@ +package data.datasource + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth +import com.lighthouse.database.BeepDatabase +import com.lighthouse.database.dao.BrandWithSectionDao +import com.lighthouse.database.entity.BrandLocationEntity +import com.lighthouse.database.entity.SectionEntity +import com.lighthouse.domain.Dms +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.DisplayName +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@ExperimentalCoroutinesApi +class BrandLocalDataSourceTest { + + private lateinit var dao: BrandWithSectionDao + private lateinit var db: BeepDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder( + context, + BeepDatabase::class.java + ).build() + dao = db.brandWithSectionDao() + } + + @After + fun closeDb() { + db.close() + } + + @Test + @DisplayName("[성공] 룸에 Brand, Section 넣기 성공") + fun insertSectionWithBrands() = runTest { + // given + dao.insertSection(sectionEntity) + dao.insertBrand(brandPlaceInfos) + + // when + val brandWithSections = dao.getBrands("test") + + println(brandWithSections?.sectionEntity) + println(sectionEntity) + + // then + brandWithSections?.brands?.forEach { + Truth.assertThat(it.sectionId).isEqualTo(brandWithSections.sectionEntity.id) + } + } + + @Test + @DisplayName("[성공] 룸에서 Section을 지우면 Brand들도 지워진다") + fun deleteSectionWithBrands() = runTest { + // given + dao.insertSection(sectionEntity) + dao.insertBrand(brandPlaceInfos) + + // when + dao.deleteSection("test") + val brands = dao.getBrands("test")?.brands + + // then + Truth.assertThat(brands.isNullOrEmpty()).isTrue() + } + + companion object { + + private val sectionEntity = SectionEntity( + id = "test", + searchDate = Date(), + x = Dms(100, 100, 100), + y = Dms(100, 100, 100) + ) + + private val brandPlaceInfos = listOf( + BrandLocationEntity( + sectionId = "test", + addressName = "경기도 용인시 기흥구", + placeName = "경기도 용인시 기흥구", + placeUrl = UUID.randomUUID().toString(), + categoryName = "test", + brand = "스타벅스", + x = "210", + y = "110" + ), + BrandLocationEntity( + sectionId = "test", + addressName = "경기도 용인시 기흥구", + placeName = "경기도 용인시 기흥구", + placeUrl = UUID.randomUUID().toString(), + brand = "스타벅스", + categoryName = "test", + x = "210", + y = "110" + ), + BrandLocationEntity( + sectionId = "test", + addressName = "경기도 용인시 기흥구", + placeName = "경기도 용인시 기흥구", + placeUrl = UUID.randomUUID().toString(), + brand = "스타벅스", + categoryName = "test", + x = "210", + y = "110" + ), + BrandLocationEntity( + sectionId = "test", + addressName = "경기도 용인시 기흥구", + placeName = "경기도 용인시 기흥구", + placeUrl = UUID.randomUUID().toString(), + brand = "스타벅스", + categoryName = "test", + x = "210", + y = "110" + ), + BrandLocationEntity( + sectionId = "test", + addressName = "경기도 용인시 기흥구", + placeName = "경기도 용인시 기흥구", + placeUrl = UUID.randomUUID().toString(), + brand = "스타벅스", + categoryName = "test", + x = "210", + y = "110" + ) + ) + } +} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 03188ec98..84133454d 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -1,9 +1,18 @@ plugins { id("java-library") id("org.jetbrains.kotlin.jvm") + kotlin("kapt") } java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + implementation(Libraries.DOMAIN_LIBRARIES) + testImplementation(TestImpl.TEST_LIBRARIES) +} +kapt { + correctErrorTypes = true } diff --git a/domain/src/main/java/com/lighthouse/domain/LocationConverter.kt b/domain/src/main/java/com/lighthouse/domain/LocationConverter.kt new file mode 100644 index 000000000..b0ec3b057 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/LocationConverter.kt @@ -0,0 +1,240 @@ +package com.lighthouse.domain + +import kotlin.math.acos +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.sin + +/** + * @property degree 도 + * @property minutes 분 + * @property seconds 초 + */ +data class Dms( + val degree: Int, + val minutes: Int, + val seconds: Int +) { + fun dmsToString() = "${degree}${fillZero(minutes)}${fillZero(seconds)}" + + private fun fillZero(seconds: Int) = seconds.toString().padStart(2, '0') +} + +/** + * @property longitude : 경도 + * @property latitude : 위도 + */ +data class VertexLocation( + val longitude: Double, + val latitude: Double +) + +data class NextLocation( + val x: Int, + val y: Int +) + +data class DmsLocation( + val x: Dms, + val y: Dms +) + +object LocationConverter { + + private const val gap = 20 + private const val searchGap = gap * 2 + + private val directions = listOf( + NextLocation(0, 0), + NextLocation(-gap, 0), + NextLocation(-gap, gap), + NextLocation(0, gap), + NextLocation(gap, gap), + NextLocation(gap, 0), + NextLocation(gap, -gap), + NextLocation(0, -gap), + NextLocation(-gap, -gap) + ) + + private val searchDirections = directions + listOf( + NextLocation(-searchGap, -searchGap), + NextLocation(-searchGap, -gap), + NextLocation(-searchGap, 0), + NextLocation(-searchGap, gap), + NextLocation(-searchGap, searchGap), + NextLocation(-gap, searchGap), + NextLocation(0, searchGap), + NextLocation(gap, searchGap), + NextLocation(searchGap, searchGap), + NextLocation(searchGap, gap), + NextLocation(searchGap, 0), + NextLocation(searchGap, -gap), + NextLocation(searchGap, -searchGap), + NextLocation(gap, -searchGap), + NextLocation(0, -searchGap), + NextLocation(-gap, -searchGap) + ) + + fun getCardinalDirections(x: Double, y: Double): List { + val xDms = toMinDms(x) + val yDms = toMinDms(y) + + return directions.map { nextLocation -> + val nextDmsX = calculateTime(nextLocation.x, xDms) + val nextDmsY = calculateTime(nextLocation.y, yDms) + DmsLocation(nextDmsX, nextDmsY) + } + } + + fun getSearchCardinalDirections(x: Dms, y: Dms): List { + return searchDirections.map { nextLocation -> + val nextDmsX = calculateTime(nextLocation.x, x) + val nextDmsY = calculateTime(nextLocation.y, y) + DmsLocation(nextDmsX, nextDmsY) + } + } + + private fun calculateTime( + nextSecond: Int, + dms: Dms + ): Dms { + val degreeToSeconds = dms.degree * 3600 + val minutesToSeconds = dms.minutes * 60 + val seconds = dms.seconds + + val sum = degreeToSeconds + minutesToSeconds + seconds + nextSecond + + val resultDegree: Int = sum / 3600 + val resultMinutes: Int = sum / 60 - (resultDegree * 60) + val resultSeconds: Int = sum % 60 + + return Dms(resultDegree, resultMinutes, resultSeconds) + } + + /** + * 도분초(DMS) : 저희가 ROOM에 저장할 좌표라고 생각하시면 편할거 같습니다 :) + * 십진수도(DD): Location 에서 갖고 왔을때 정보가 DD입니다! + * 십진수를 도분초로 바꾸는 함수입니다. + * @param coordinate -> x,y 좌표 + */ + fun toMinDms(coordinate: Double): Dms { + val dms = setDms(coordinate) + + val depressionSeconds = getDepression(dms.seconds) + + return dms.copy( + degree = dms.degree, + minutes = dms.minutes, + seconds = depressionSeconds + ) + } + + private fun setDms(coordinateToDouble: Double): Dms { + val degree = getDegree(coordinateToDouble) + val minutes = getMinutes(coordinateToDouble, degree) + val seconds = getSeconds(coordinateToDouble, degree, minutes) + return Dms(degree, minutes, seconds) + } + + private fun getDegree(coordinateToDouble: Double) = floor(coordinateToDouble).toInt() + + private fun getMinutes(coordinateToDouble: Double, degree: Int) = getDegree((coordinateToDouble - degree) * 60) + + /** + * @param coordinateToDouble -> x,y 좌표 double 값 + * @param degree -> 도 + * @param minutes -> 분 + */ + private fun getSeconds(coordinateToDouble: Double, degree: Int, minutes: Int) = + getMinutes((coordinateToDouble - degree) * 60, minutes) + + private fun getDepression(value: Int) = value - value % gap + + /** + * x, y 좌표를 기준으로 각 꼭짓점을 찾아주는 함수입니다. + * @param x section 의 최소 x 값 + * @param y section 의 최소 x 값 + * @return (좌측 X 좌표, 좌측 Y 좌표, 우측 X 좌표, 우측 Y 좌표 형식) + */ + fun getVertex(x: Dms, y: Dms): String { + val (maxX, maxY) = calculateVertex(x, y, searchGap + gap) + val (minX, minY) = calculateVertex(x, y, -searchGap) + return "$minX,$minY,$maxX,$maxY" + } + + /** + * section 에서 좌측 하단의 좌표를 이용해서 우측 상단의 좌표를 구하는 코드입니다. + * @param x section x + * @param y section y + * @return DMS(도분초) 에서 DD(십진수)로 변환해서 반환을 합니다. + */ + private fun calculateVertex(x: Dms, y: Dms, value: Int): VertexLocation { + val calculateX = calculateTime(value, x) + val calculateY = calculateTime(value, y) + + return VertexLocation(convertToDD(calculateX), convertToDD(calculateY)) + } + + /** + * DMS(도분초, 60진수) to DD(10진수) 변환 + * @param dms : 도.분.초 + * @return 도분초를 각각 계산해서 하나의 좌표로 만들어줍니다. + */ + fun convertToDD(dms: Dms) = + dms.degree.toDouble() + (dms.minutes.toDouble() / 60.0) + (dms.seconds.toDouble() / 3600.0) + + /** 참고 : https://www.geodatasource.com/developers/java + * 두 좌표 사이의 거리를 구하는 함수 + * @param lat1 기준 x + * @param lon1 기준 y + * @param lat2 비교 x + * @param lon2 비교 y + * @return 미터 단위로 반환 + */ + private fun locationDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val theta = lon1 - lon2 + var dist = + sin(decimalToRadian(lat1)) * sin(decimalToRadian(lat2)) + cos(decimalToRadian(lat1)) * + cos(decimalToRadian(lat2)) * cos(decimalToRadian(theta)) + dist = acos(dist) + dist = radianToDecimal(dist) + dist *= 60 * 1.1515 * 1609.344 + return dist // 단위 meter + } + + /** + * 10진수를 radian(라디안)으로 변환 + * @param deg 10진수 + * @return radian 반환 + */ + private fun decimalToRadian(deg: Double): Double { + return deg * Math.PI / 180.0 + } + + /** + * radian(라디안)을 10진수로 변환 + * @param radian + * @return + */ + private fun radianToDecimal(radian: Double): Double { + return radian * 180 / Math.PI + } + + fun setDmsLocation(lastLocationResult: VertexLocation): DmsLocation { + val x = toMinDms(lastLocationResult.longitude) + val y = toMinDms(lastLocationResult.latitude) + return DmsLocation(x, y) + } + + fun diffLocation( + brandX: String, + brandY: String, + x: Double, + y: Double + ) = locationDistance( + brandX.toDouble(), + brandY.toDouble(), + x, + y + ) +} diff --git a/domain/src/main/java/com/lighthouse/domain/model/BeepError.kt b/domain/src/main/java/com/lighthouse/domain/model/BeepError.kt new file mode 100644 index 000000000..b60293645 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/BeepError.kt @@ -0,0 +1,9 @@ +package com.lighthouse.domain.model + +sealed class BeepError( + override val message: String? = null, + override val cause: Throwable? = null +) : Exception(message, cause) { + + object NetworkFailure : BeepError() +} diff --git a/domain/src/main/java/com/lighthouse/domain/model/Brand.kt b/domain/src/main/java/com/lighthouse/domain/model/Brand.kt new file mode 100644 index 000000000..b7b01fcbf --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/Brand.kt @@ -0,0 +1,6 @@ +package com.lighthouse.domain.model + +data class Brand( + val name: String, + val count: Int +) diff --git a/domain/src/main/java/com/lighthouse/domain/model/BrandPlaceInfo.kt b/domain/src/main/java/com/lighthouse/domain/model/BrandPlaceInfo.kt new file mode 100644 index 000000000..a98d80f1f --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/BrandPlaceInfo.kt @@ -0,0 +1,11 @@ +package com.lighthouse.domain.model + +data class BrandPlaceInfo( + val addressName: String, + val placeName: String, + val categoryName: String, + val placeUrl: String, + val brand: String, + val x: String, + val y: String +) diff --git a/domain/src/main/java/com/lighthouse/domain/model/DbResult.kt b/domain/src/main/java/com/lighthouse/domain/model/DbResult.kt new file mode 100644 index 000000000..e9972564d --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/DbResult.kt @@ -0,0 +1,8 @@ +package com.lighthouse.domain.model + +sealed class DbResult { + data class Success(val data: T) : DbResult() + object Loading : DbResult() + object Empty : DbResult() + data class Failure(val throwable: Throwable) : DbResult() +} diff --git a/domain/src/main/java/com/lighthouse/domain/model/GalleryImage.kt b/domain/src/main/java/com/lighthouse/domain/model/GalleryImage.kt new file mode 100644 index 000000000..a95f8e689 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/GalleryImage.kt @@ -0,0 +1,9 @@ +package com.lighthouse.domain.model + +import java.util.Date + +data class GalleryImage( + val id: Long, + val contentUri: String, + val date: Date +) diff --git a/domain/src/main/java/com/lighthouse/domain/model/Gifticon.kt b/domain/src/main/java/com/lighthouse/domain/model/Gifticon.kt new file mode 100644 index 000000000..ac7dff4f6 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/Gifticon.kt @@ -0,0 +1,19 @@ +package com.lighthouse.domain.model + +import java.util.Date + +data class Gifticon( + val id: String, + val createdAt: Date, + val userId: String, + val hasImage: Boolean, + val croppedUri: String, + val name: String, + val brand: String, + val expireAt: Date, + val barcode: String, + val isCashCard: Boolean, + val balance: Int, + val memo: String, + val isUsed: Boolean +) diff --git a/domain/src/main/java/com/lighthouse/domain/model/GifticonCrop.kt b/domain/src/main/java/com/lighthouse/domain/model/GifticonCrop.kt new file mode 100644 index 000000000..ee75e2518 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/GifticonCrop.kt @@ -0,0 +1,8 @@ +package com.lighthouse.domain.model + +data class GifticonCrop( + val gifticonId: String, + val rect: Rectangle +) { + val originPath = "origin$gifticonId" +} diff --git a/domain/src/main/java/com/lighthouse/domain/model/GifticonForAddition.kt b/domain/src/main/java/com/lighthouse/domain/model/GifticonForAddition.kt new file mode 100644 index 000000000..c067dbf7c --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/GifticonForAddition.kt @@ -0,0 +1,17 @@ +package com.lighthouse.domain.model + +import java.util.Date + +data class GifticonForAddition( + val hasImage: Boolean, + val name: String, + val brandName: String, + val barcode: String, + val expiredAt: Date, + val isCashCard: Boolean, + val balance: Int, + val originUri: String, + val tempCroppedUri: String, + val croppedRect: Rectangle, + val memo: String +) diff --git a/domain/src/main/java/com/lighthouse/domain/model/GifticonForUpdate.kt b/domain/src/main/java/com/lighthouse/domain/model/GifticonForUpdate.kt new file mode 100644 index 000000000..99c6e057b --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/GifticonForUpdate.kt @@ -0,0 +1,24 @@ +package com.lighthouse.domain.model + +import java.util.Date + +data class GifticonForUpdate( + val id: String, + val userId: String, + val hasImage: Boolean, + val name: String, + val brandName: String, + val barcode: String, + val expiredAt: Date, + val isCashCard: Boolean, + val balance: Int, + val oldCroppedUri: String, + val croppedUri: String, + val croppedRect: Rectangle, + val memo: String, + val isUsed: Boolean, + val createdAt: Date +) { + val isUpdatedImage + get() = oldCroppedUri != croppedUri +} diff --git a/domain/src/main/java/com/lighthouse/domain/model/Rectangle.kt b/domain/src/main/java/com/lighthouse/domain/model/Rectangle.kt new file mode 100644 index 000000000..acf1d0d69 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/Rectangle.kt @@ -0,0 +1,15 @@ +package com.lighthouse.domain.model + +data class Rectangle( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int +) { + + val width + get() = right - left + + val height + get() = bottom - top +} diff --git a/domain/src/main/java/com/lighthouse/domain/model/SortBy.kt b/domain/src/main/java/com/lighthouse/domain/model/SortBy.kt new file mode 100644 index 000000000..e74f720e4 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/SortBy.kt @@ -0,0 +1,6 @@ +package com.lighthouse.domain.model + +enum class SortBy { + RECENT, + DEADLINE +} diff --git a/domain/src/main/java/com/lighthouse/domain/model/UsageHistory.kt b/domain/src/main/java/com/lighthouse/domain/model/UsageHistory.kt new file mode 100644 index 000000000..b02df2236 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/UsageHistory.kt @@ -0,0 +1,10 @@ +package com.lighthouse.domain.model + +import com.lighthouse.domain.VertexLocation +import java.util.Date + +data class UsageHistory( + val date: Date, + val location: VertexLocation?, + val amount: Int +) diff --git a/domain/src/main/java/com/lighthouse/domain/model/UserPreferenceOption.kt b/domain/src/main/java/com/lighthouse/domain/model/UserPreferenceOption.kt new file mode 100644 index 000000000..8ab80cf31 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/model/UserPreferenceOption.kt @@ -0,0 +1,7 @@ +package com.lighthouse.domain.model + +enum class UserPreferenceOption { + SECURITY, + NOTIFICATION, + GUEST +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/AuthRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/AuthRepository.kt new file mode 100644 index 000000000..f54e5a306 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/AuthRepository.kt @@ -0,0 +1,6 @@ +package com.lighthouse.domain.repository + +interface AuthRepository { + + fun getCurrentUserId(): String +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/BrandRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/BrandRepository.kt new file mode 100644 index 000000000..74e01f131 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/BrandRepository.kt @@ -0,0 +1,8 @@ +package com.lighthouse.domain.repository + +import com.lighthouse.domain.Dms +import com.lighthouse.domain.model.BrandPlaceInfo + +interface BrandRepository { + suspend fun getBrandPlaceInfo(brandName: String, x: Dms, y: Dms, size: Int): Result> +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/GalleryImageRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/GalleryImageRepository.kt new file mode 100644 index 000000000..44668d9d7 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/GalleryImageRepository.kt @@ -0,0 +1,9 @@ +package com.lighthouse.domain.repository + +import androidx.paging.PagingData +import com.lighthouse.domain.model.GalleryImage +import kotlinx.coroutines.flow.Flow + +interface GalleryImageRepository { + fun getImages(pageSize: Int = 10): Flow> +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/GifticonImageRecognizeRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/GifticonImageRecognizeRepository.kt new file mode 100644 index 000000000..7cb6dc012 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/GifticonImageRecognizeRepository.kt @@ -0,0 +1,20 @@ +package com.lighthouse.domain.repository + +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.domain.model.GifticonForAddition +import java.util.Date + +interface GifticonImageRecognizeRepository { + + suspend fun recognize(gallery: GalleryImage): GifticonForAddition? + + suspend fun recognizeGifticonName(path: String): String + + suspend fun recognizeBrandName(path: String): String + + suspend fun recognizeBarcode(path: String): String + + suspend fun recognizeBalance(path: String): Int + + suspend fun recognizeExpired(path: String): Date +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/GifticonRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/GifticonRepository.kt new file mode 100644 index 000000000..aa2b06a50 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/GifticonRepository.kt @@ -0,0 +1,39 @@ +package com.lighthouse.domain.repository + +import com.lighthouse.domain.model.Brand +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.domain.model.SortBy +import com.lighthouse.domain.model.UsageHistory +import kotlinx.coroutines.flow.Flow + +interface GifticonRepository { + + fun getGifticon(id: String): Flow> + fun getAllGifticons(userId: String, sortBy: SortBy = SortBy.DEADLINE): Flow>> + fun getAllUsedGifticons(userId: String): Flow>> + fun getFilteredGifticons( + userId: String, + filter: Set, + sortBy: SortBy = SortBy.DEADLINE + ): Flow>> + + fun getAllBrands(userId: String, filterExpired: Boolean): Flow>> + suspend fun saveGifticons(userId: String, gifticonForAdditions: List) + suspend fun getGifticonCrop(userId: String, id: String): GifticonForUpdate? + suspend fun updateGifticon(gifticonForUpdate: GifticonForUpdate) + fun getUsageHistory(gifticonId: String): Flow>> + suspend fun saveUsageHistory(gifticonId: String, usageHistory: UsageHistory) + suspend fun useGifticon(gifticonId: String, usageHistory: UsageHistory) + suspend fun useCashCardGifticon(gifticonId: String, amount: Int, usageHistory: UsageHistory) + suspend fun unUseGifticon(gifticonId: String) + suspend fun removeGifticon(gifticonId: String) + fun getGifticonByBrand(brand: String): Flow>> + fun hasUsableGifticon(userId: String): Flow + fun getUsableGifticons(userId: String): Flow>> + suspend fun hasGifticonBrand(brand: String): Boolean + + suspend fun moveUserIdGifticon(oldUserId: String, newUserId: String) +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/LocationRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/LocationRepository.kt new file mode 100644 index 000000000..43b673b92 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/LocationRepository.kt @@ -0,0 +1,9 @@ +package com.lighthouse.domain.repository + +import com.lighthouse.domain.VertexLocation +import kotlinx.coroutines.flow.Flow + +interface LocationRepository { + + fun getLocations(): Flow +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/SecurityRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/SecurityRepository.kt new file mode 100644 index 000000000..508f0e607 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/SecurityRepository.kt @@ -0,0 +1,7 @@ +package com.lighthouse.domain.repository + +import javax.crypto.Cipher + +interface SecurityRepository { + fun getFingerprintCipher(): Cipher +} diff --git a/domain/src/main/java/com/lighthouse/domain/repository/UserPreferencesRepository.kt b/domain/src/main/java/com/lighthouse/domain/repository/UserPreferencesRepository.kt new file mode 100644 index 000000000..0cc66f006 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/repository/UserPreferencesRepository.kt @@ -0,0 +1,20 @@ +package com.lighthouse.domain.repository + +import com.lighthouse.domain.model.UserPreferenceOption +import kotlinx.coroutines.flow.Flow + +interface UserPreferencesRepository { + suspend fun setPinString(pinString: String): Result + fun getPinString(): Flow + + suspend fun setSecurityOption(value: Int): Result + fun getSecurityOption(): Flow + + suspend fun setBooleanOption(option: UserPreferenceOption, value: Boolean): Result + fun getBooleanOption(option: UserPreferenceOption): Flow + + fun isStored(option: UserPreferenceOption): Flow + + suspend fun moveGuestData(uid: String): Result + suspend fun removeCurrentUserData(): Result +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetAllBrandsUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetAllBrandsUseCase.kt new file mode 100644 index 000000000..0b2dd7299 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetAllBrandsUseCase.kt @@ -0,0 +1,26 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.model.Brand +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.GifticonRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform +import javax.inject.Inject + +class GetAllBrandsUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + authRepository: AuthRepository +) { + val userId = authRepository.getCurrentUserId() + + operator fun invoke(filterExpired: Boolean = false): Flow>> { + return gifticonRepository.getAllBrands(userId, filterExpired).transform { + if (it is DbResult.Success) { + emit(DbResult.Success(it.data.sortedByDescending { brand -> brand.count })) + } else { + emit(it) + } + } + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetBrandPlaceInfosUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetBrandPlaceInfosUseCase.kt new file mode 100644 index 000000000..3b4f41537 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetBrandPlaceInfosUseCase.kt @@ -0,0 +1,28 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.LocationConverter +import com.lighthouse.domain.model.BrandPlaceInfo +import com.lighthouse.domain.repository.BrandRepository +import javax.inject.Inject + +class GetBrandPlaceInfosUseCase @Inject constructor( + private val brandRepository: BrandRepository +) { + + suspend operator fun invoke( + brandNames: List, + x: Double, + y: Double, + size: Int + ): Result> { + val cardinalLocations = LocationConverter.getCardinalDirections(x, y) + + return runCatching { + cardinalLocations.flatMap { location -> + brandNames.flatMap { brandName -> + brandRepository.getBrandPlaceInfo(brandName, location.x, location.y, size).getOrThrow() + } + } + } + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetFilteredGifticonsUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetFilteredGifticonsUseCase.kt new file mode 100644 index 000000000..6a0b7433a --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetFilteredGifticonsUseCase.kt @@ -0,0 +1,24 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.model.SortBy +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.GifticonRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetFilteredGifticonsUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + authRepository: AuthRepository +) { + val userId = authRepository.getCurrentUserId() + + operator fun invoke(filter: Set, sortBy: SortBy = SortBy.DEADLINE): Flow>> { + return if (filter.isEmpty()) { + gifticonRepository.getAllGifticons(userId, sortBy) + } else { + gifticonRepository.getFilteredGifticons(userId, filter, sortBy) + } + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetFingerprintCipherUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetFingerprintCipherUseCase.kt new file mode 100644 index 000000000..5e8d078cb --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetFingerprintCipherUseCase.kt @@ -0,0 +1,14 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.repository.SecurityRepository +import javax.crypto.Cipher +import javax.inject.Inject + +class GetFingerprintCipherUseCase @Inject constructor( + private val securityRepository: SecurityRepository +) { + + operator fun invoke(): Cipher { + return securityRepository.getFingerprintCipher() + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetGifticonUseCase.kt new file mode 100644 index 000000000..01127a9f2 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetGifticonUseCase.kt @@ -0,0 +1,16 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.repository.GifticonRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository +) { + + operator fun invoke(id: String): Flow> { + return gifticonRepository.getGifticon(id) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetGifticonsUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetGifticonsUseCase.kt new file mode 100644 index 000000000..c959a2bcf --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetGifticonsUseCase.kt @@ -0,0 +1,27 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.GifticonRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetGifticonsUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + authRepository: AuthRepository +) { + val userId = authRepository.getCurrentUserId() + + operator fun invoke(): Flow>> { + return gifticonRepository.getAllGifticons(userId) + } + + fun getUsableGifticons(): Flow>> { + return gifticonRepository.getUsableGifticons(userId) + } + + fun getUsedGifticons(): Flow>> { + return gifticonRepository.getAllUsedGifticons(userId) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetSharedDateUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetSharedDateUseCase.kt new file mode 100644 index 000000000..f1035ebf6 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetSharedDateUseCase.kt @@ -0,0 +1,10 @@ +package com.lighthouse.domain.usecase + +import java.util.Date + +class GetSharedDateUseCase { + + operator fun invoke(id: String): Date? { + return null + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetUsageHistoriesUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetUsageHistoriesUseCase.kt new file mode 100644 index 000000000..fa1b24b3a --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetUsageHistoriesUseCase.kt @@ -0,0 +1,16 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.UsageHistory +import com.lighthouse.domain.repository.GifticonRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetUsageHistoriesUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository +) { + + operator fun invoke(gifticonId: String): Flow>> { + return gifticonRepository.getUsageHistory(gifticonId) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/GetUserLocationUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/GetUserLocationUseCase.kt new file mode 100644 index 000000000..58058d4b6 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/GetUserLocationUseCase.kt @@ -0,0 +1,11 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.repository.LocationRepository +import javax.inject.Inject + +class GetUserLocationUseCase @Inject constructor( + private val repository: LocationRepository +) { + + operator fun invoke() = repository.getLocations() +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/HasVariableGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/HasVariableGifticonUseCase.kt new file mode 100644 index 000000000..be1a92b23 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/HasVariableGifticonUseCase.kt @@ -0,0 +1,18 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.GifticonRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class HasVariableGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + authRepository: AuthRepository +) { + + val userId = authRepository.getCurrentUserId() + + operator fun invoke(): Flow { + return gifticonRepository.hasUsableGifticon(userId) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/MoveUserIdGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/MoveUserIdGifticonUseCase.kt new file mode 100644 index 000000000..8dc7aa512 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/MoveUserIdGifticonUseCase.kt @@ -0,0 +1,12 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.repository.GifticonRepository +import javax.inject.Inject + +class MoveUserIdGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository +) { + suspend operator fun invoke(oldUserId: String, newUserId: String) { + gifticonRepository.moveUserIdGifticon(oldUserId, newUserId) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/RemoveGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/RemoveGifticonUseCase.kt new file mode 100644 index 000000000..7d04aae84 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/RemoveGifticonUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.repository.GifticonRepository +import javax.inject.Inject + +class RemoveGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository +) { + + suspend operator fun invoke(gifticonId: String) { + gifticonRepository.removeGifticon(gifticonId) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/UnUseGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/UnUseGifticonUseCase.kt new file mode 100644 index 000000000..326c66728 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/UnUseGifticonUseCase.kt @@ -0,0 +1,12 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.repository.GifticonRepository +import javax.inject.Inject + +class UnUseGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository +) { + suspend operator fun invoke(gifticonId: String) { + gifticonRepository.unUseGifticon(gifticonId) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/UpdateSharedDateUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/UpdateSharedDateUseCase.kt new file mode 100644 index 000000000..fd733f626 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/UpdateSharedDateUseCase.kt @@ -0,0 +1,8 @@ +package com.lighthouse.domain.usecase + +class UpdateSharedDateUseCase { + + operator fun invoke(id: String): Result { + return Result.success(Unit) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/UpdateUsageHistoryUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/UpdateUsageHistoryUseCase.kt new file mode 100644 index 000000000..43f612dc1 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/UpdateUsageHistoryUseCase.kt @@ -0,0 +1,8 @@ +package com.lighthouse.domain.usecase + +class UpdateUsageHistoryUseCase { + + operator fun invoke(id: String): Result { + return Result.success(Unit) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/UseCashCardGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/UseCashCardGifticonUseCase.kt new file mode 100644 index 000000000..a492b584e --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/UseCashCardGifticonUseCase.kt @@ -0,0 +1,20 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.model.UsageHistory +import com.lighthouse.domain.repository.GifticonRepository +import com.lighthouse.domain.util.currentTime +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class UseCashCardGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + private val getUserLocationUseCase: GetUserLocationUseCase +) { + + suspend operator fun invoke(gifticonId: String, amount: Int, hasLocationPermission: Boolean) { + val userLocation = if (hasLocationPermission) getUserLocationUseCase().first() else null + val usageHistory = UsageHistory(currentTime, userLocation, amount) + + gifticonRepository.useCashCardGifticon(gifticonId, amount, usageHistory) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/UseGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/UseGifticonUseCase.kt new file mode 100644 index 000000000..2857d2d9e --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/UseGifticonUseCase.kt @@ -0,0 +1,19 @@ +package com.lighthouse.domain.usecase + +import com.lighthouse.domain.model.UsageHistory +import com.lighthouse.domain.repository.GifticonRepository +import com.lighthouse.domain.util.currentTime +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class UseGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + private val getUserLocationUseCase: GetUserLocationUseCase +) { + suspend operator fun invoke(gifticonId: String, hasLocationPermission: Boolean) { + val userLocation = if (hasLocationPermission) getUserLocationUseCase().first() else null + val usageHistory = UsageHistory(currentTime, userLocation, 0) + + gifticonRepository.useGifticon(gifticonId, usageHistory) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/HasGifticonBrandUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/HasGifticonBrandUseCase.kt new file mode 100644 index 000000000..f497b7cac --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/HasGifticonBrandUseCase.kt @@ -0,0 +1,12 @@ +package com.lighthouse.domain.usecase.edit + +import com.lighthouse.domain.repository.GifticonRepository +import javax.inject.Inject + +class HasGifticonBrandUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository +) { + suspend operator fun invoke(brand: String): Boolean { + return gifticonRepository.hasGifticonBrand(brand) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBalanceUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBalanceUseCase.kt new file mode 100644 index 000000000..5860dbcc3 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBalanceUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase.edit + +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import javax.inject.Inject + +class RecognizeBalanceUseCase @Inject constructor( + private val gifticonImageRecognizeRepository: GifticonImageRecognizeRepository +) { + + suspend operator fun invoke(uri: String): Int { + return gifticonImageRecognizeRepository.recognizeBalance(uri) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBarcodeUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBarcodeUseCase.kt new file mode 100644 index 000000000..057247649 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBarcodeUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase.edit + +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import javax.inject.Inject + +class RecognizeBarcodeUseCase @Inject constructor( + private val gifticonImageRecognizeRepository: GifticonImageRecognizeRepository +) { + + suspend operator fun invoke(uri: String): String { + return gifticonImageRecognizeRepository.recognizeBarcode(uri) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBrandNameUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBrandNameUseCase.kt new file mode 100644 index 000000000..9b1fd334a --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeBrandNameUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase.edit + +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import javax.inject.Inject + +class RecognizeBrandNameUseCase @Inject constructor( + private val gifticonImageRecognizeRepository: GifticonImageRecognizeRepository +) { + + suspend operator fun invoke(uri: String): String { + return gifticonImageRecognizeRepository.recognizeBrandName(uri) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeExpiredUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeExpiredUseCase.kt new file mode 100644 index 000000000..3fed594ac --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeExpiredUseCase.kt @@ -0,0 +1,14 @@ +package com.lighthouse.domain.usecase.edit + +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import java.util.Date +import javax.inject.Inject + +class RecognizeExpiredUseCase @Inject constructor( + private val gifticonImageRecognizeRepository: GifticonImageRecognizeRepository +) { + + suspend operator fun invoke(uri: String): Date { + return gifticonImageRecognizeRepository.recognizeExpired(uri) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeGifticonImageUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeGifticonImageUseCase.kt new file mode 100644 index 000000000..d174ed2ed --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeGifticonImageUseCase.kt @@ -0,0 +1,15 @@ +package com.lighthouse.domain.usecase.edit + +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import javax.inject.Inject + +class RecognizeGifticonImageUseCase @Inject constructor( + private val gifticonImageRecognizeRepository: GifticonImageRecognizeRepository +) { + + suspend operator fun invoke(galleryImage: GalleryImage): GifticonForAddition? { + return gifticonImageRecognizeRepository.recognize(galleryImage) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeGifticonNameUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeGifticonNameUseCase.kt new file mode 100644 index 000000000..50879e5f6 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/RecognizeGifticonNameUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase.edit + +import com.lighthouse.domain.repository.GifticonImageRecognizeRepository +import javax.inject.Inject + +class RecognizeGifticonNameUseCase @Inject constructor( + private val gifticonImageRecognizeRepository: GifticonImageRecognizeRepository +) { + + suspend operator fun invoke(uri: String): String { + return gifticonImageRecognizeRepository.recognizeGifticonName(uri) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/addgifticon/AddRecognizeUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/addgifticon/AddRecognizeUseCase.kt new file mode 100644 index 000000000..2f58e9361 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/addgifticon/AddRecognizeUseCase.kt @@ -0,0 +1,35 @@ +package com.lighthouse.domain.usecase.edit.addgifticon + +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.domain.usecase.edit.RecognizeBalanceUseCase +import com.lighthouse.domain.usecase.edit.RecognizeBarcodeUseCase +import com.lighthouse.domain.usecase.edit.RecognizeBrandNameUseCase +import com.lighthouse.domain.usecase.edit.RecognizeExpiredUseCase +import com.lighthouse.domain.usecase.edit.RecognizeGifticonImageUseCase +import com.lighthouse.domain.usecase.edit.RecognizeGifticonNameUseCase +import java.util.Date +import javax.inject.Inject + +class AddRecognizeUseCase @Inject constructor( + private val recognizeGifticonImageUseCase: RecognizeGifticonImageUseCase, + private val recognizeGifticonNameUseCase: RecognizeGifticonNameUseCase, + private val recognizeBrandNameUseCase: RecognizeBrandNameUseCase, + private val recognizeBarcodeUseCase: RecognizeBarcodeUseCase, + private val recognizeBalanceUseCase: RecognizeBalanceUseCase, + private val recognizeExpiredUseCase: RecognizeExpiredUseCase +) { + suspend fun gifticon(galleryImage: GalleryImage): GifticonForAddition? { + return recognizeGifticonImageUseCase(galleryImage) + } + + suspend fun gifticonName(uri: String): String = recognizeGifticonNameUseCase(uri) + + suspend fun brandName(uri: String): String = recognizeBrandNameUseCase(uri) + + suspend fun barcode(uri: String): String = recognizeBarcodeUseCase(uri) + + suspend fun balance(uri: String): Int = recognizeBalanceUseCase(uri) + + suspend fun expired(uri: String): Date = recognizeExpiredUseCase(uri) +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/addgifticon/SaveGifticonsUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/addgifticon/SaveGifticonsUseCase.kt new file mode 100644 index 000000000..95a73a8a5 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/addgifticon/SaveGifticonsUseCase.kt @@ -0,0 +1,16 @@ +package com.lighthouse.domain.usecase.edit.addgifticon + +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.GifticonRepository +import javax.inject.Inject + +class SaveGifticonsUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + private val authRepository: AuthRepository +) { + + suspend operator fun invoke(gifticons: List) { + gifticonRepository.saveGifticons(authRepository.getCurrentUserId(), gifticons) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/GetGifticonForUpdateUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/GetGifticonForUpdateUseCase.kt new file mode 100644 index 000000000..5391fa3ba --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/GetGifticonForUpdateUseCase.kt @@ -0,0 +1,16 @@ +package com.lighthouse.domain.usecase.edit.modifygifticon + +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.GifticonRepository +import javax.inject.Inject + +class GetGifticonForUpdateUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + private val authRepository: AuthRepository +) { + + suspend operator fun invoke(id: String): GifticonForUpdate? { + return gifticonRepository.getGifticonCrop(authRepository.getCurrentUserId(), id) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/ModifyGifticonUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/ModifyGifticonUseCase.kt new file mode 100644 index 000000000..1c8ed72a7 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/ModifyGifticonUseCase.kt @@ -0,0 +1,18 @@ +package com.lighthouse.domain.usecase.edit.modifygifticon + +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.domain.repository.AuthRepository +import com.lighthouse.domain.repository.GifticonRepository +import javax.inject.Inject + +class ModifyGifticonUseCase @Inject constructor( + private val gifticonRepository: GifticonRepository, + private val authRepository: AuthRepository +) { + + suspend operator fun invoke(gifticonForUpdate: GifticonForUpdate) { + if (authRepository.getCurrentUserId() == gifticonForUpdate.userId) { + gifticonRepository.updateGifticon(gifticonForUpdate) + } + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/ModifyRecognizeUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/ModifyRecognizeUseCase.kt new file mode 100644 index 000000000..3d6124c85 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/edit/modifygifticon/ModifyRecognizeUseCase.kt @@ -0,0 +1,27 @@ +package com.lighthouse.domain.usecase.edit.modifygifticon + +import com.lighthouse.domain.usecase.edit.RecognizeBalanceUseCase +import com.lighthouse.domain.usecase.edit.RecognizeBarcodeUseCase +import com.lighthouse.domain.usecase.edit.RecognizeBrandNameUseCase +import com.lighthouse.domain.usecase.edit.RecognizeExpiredUseCase +import com.lighthouse.domain.usecase.edit.RecognizeGifticonNameUseCase +import java.util.Date +import javax.inject.Inject + +class ModifyRecognizeUseCase @Inject constructor( + private val recognizeGifticonNameUseCase: RecognizeGifticonNameUseCase, + private val recognizeBrandNameUseCase: RecognizeBrandNameUseCase, + private val recognizeBarcodeUseCase: RecognizeBarcodeUseCase, + private val recognizeBalanceUseCase: RecognizeBalanceUseCase, + private val recognizeExpiredUseCase: RecognizeExpiredUseCase +) { + suspend fun gifticonName(uri: String): String = recognizeGifticonNameUseCase(uri) + + suspend fun brandName(uri: String): String = recognizeBrandNameUseCase(uri) + + suspend fun barcode(uri: String): String = recognizeBarcodeUseCase(uri) + + suspend fun balance(uri: String): Int = recognizeBalanceUseCase(uri) + + suspend fun expired(uri: String): Date = recognizeExpiredUseCase(uri) +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/gallery/GetGalleryImagesUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/gallery/GetGalleryImagesUseCase.kt new file mode 100644 index 000000000..7cc144a3a --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/gallery/GetGalleryImagesUseCase.kt @@ -0,0 +1,16 @@ +package com.lighthouse.domain.usecase.gallery + +import androidx.paging.PagingData +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.domain.repository.GalleryImageRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetGalleryImagesUseCase @Inject constructor( + private val galleryImageRepository: GalleryImageRepository +) { + + operator fun invoke(): Flow> { + return galleryImageRepository.getImages() + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetCorrespondWithPinUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetCorrespondWithPinUseCase.kt new file mode 100644 index 000000000..f8082f49c --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetCorrespondWithPinUseCase.kt @@ -0,0 +1,15 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.repository.UserPreferencesRepository +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class GetCorrespondWithPinUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + + suspend operator fun invoke(pinString: String): Boolean { + val correctPinString = userPreferencesRepository.getPinString() + return pinString == correctPinString.first() + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetGuestOptionUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetGuestOptionUseCase.kt new file mode 100644 index 000000000..33fcc3064 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetGuestOptionUseCase.kt @@ -0,0 +1,14 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.repository.UserPreferencesRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetGuestOptionUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + operator fun invoke(): Flow { + return userPreferencesRepository.getBooleanOption(UserPreferenceOption.GUEST) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetNotificationOptionUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetNotificationOptionUseCase.kt new file mode 100644 index 000000000..6657eb8fa --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetNotificationOptionUseCase.kt @@ -0,0 +1,14 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.repository.UserPreferencesRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetNotificationOptionUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + operator fun invoke(): Flow { + return userPreferencesRepository.getBooleanOption(UserPreferenceOption.NOTIFICATION) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetOptionStoredUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetOptionStoredUseCase.kt new file mode 100644 index 000000000..80b73b494 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetOptionStoredUseCase.kt @@ -0,0 +1,14 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.repository.UserPreferencesRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetOptionStoredUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + operator fun invoke(option: UserPreferenceOption): Flow { + return userPreferencesRepository.isStored(option) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetSecurityOptionUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetSecurityOptionUseCase.kt new file mode 100644 index 000000000..222b7e9a1 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/GetSecurityOptionUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.repository.UserPreferencesRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetSecurityOptionUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + operator fun invoke(): Flow { + return userPreferencesRepository.getSecurityOption() + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/MoveGuestDataUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/MoveGuestDataUseCase.kt new file mode 100644 index 000000000..e85a93b2b --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/MoveGuestDataUseCase.kt @@ -0,0 +1,12 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.repository.UserPreferencesRepository +import javax.inject.Inject + +class MoveGuestDataUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + suspend operator fun invoke(uid: String): Result { + return userPreferencesRepository.moveGuestData(uid) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/RemoveUserDataUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/RemoveUserDataUseCase.kt new file mode 100644 index 000000000..2d6c6af99 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/RemoveUserDataUseCase.kt @@ -0,0 +1,12 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.repository.UserPreferencesRepository +import javax.inject.Inject + +class RemoveUserDataUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + suspend operator fun invoke(): Result { + return userPreferencesRepository.removeCurrentUserData() + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveGuestOptionUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveGuestOptionUseCase.kt new file mode 100644 index 000000000..53834844a --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveGuestOptionUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.repository.UserPreferencesRepository +import javax.inject.Inject + +class SaveGuestOptionUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + suspend operator fun invoke(option: Boolean): Result { + return userPreferencesRepository.setBooleanOption(UserPreferenceOption.GUEST, option) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveNotificationOptionUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveNotificationOptionUseCase.kt new file mode 100644 index 000000000..2ec929e8f --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveNotificationOptionUseCase.kt @@ -0,0 +1,14 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.repository.UserPreferencesRepository +import javax.inject.Inject + +class SaveNotificationOptionUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + + suspend operator fun invoke(option: Boolean): Result { + return userPreferencesRepository.setBooleanOption(UserPreferenceOption.NOTIFICATION, option) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/SavePinUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SavePinUseCase.kt new file mode 100644 index 000000000..1659448e9 --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SavePinUseCase.kt @@ -0,0 +1,13 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.repository.UserPreferencesRepository +import javax.inject.Inject + +class SavePinUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + + suspend operator fun invoke(pinString: String): Result { + return userPreferencesRepository.setPinString(pinString) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveSecurityOptionUseCase.kt b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveSecurityOptionUseCase.kt new file mode 100644 index 000000000..cdb5fcbae --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/usecase/setting/SaveSecurityOptionUseCase.kt @@ -0,0 +1,12 @@ +package com.lighthouse.domain.usecase.setting + +import com.lighthouse.domain.repository.UserPreferencesRepository +import javax.inject.Inject + +class SaveSecurityOptionUseCase @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + suspend operator fun invoke(option: Int) { + userPreferencesRepository.setSecurityOption(option) + } +} diff --git a/domain/src/main/java/com/lighthouse/domain/util/DateTime.kt b/domain/src/main/java/com/lighthouse/domain/util/DateTime.kt new file mode 100644 index 000000000..5f37e4cce --- /dev/null +++ b/domain/src/main/java/com/lighthouse/domain/util/DateTime.kt @@ -0,0 +1,33 @@ +package com.lighthouse.domain.util + +import java.util.Calendar +import java.util.Date + +val today: Date + get() = Date().adjust() + +val currentTime: Date + get() = Calendar.getInstance().time + +/** + * 현재 Date 가 과거 일자인지 계산한다 + */ +fun Date.isExpired(): Boolean = this@isExpired.adjust().time < today.time + +/** + * 현재 Date 의 디데이를 계산한다 + */ +fun Date.calcDday(): Int = ((this@calcDday.adjust() - today).time / (1000 * 60 * 60 * 24)).toInt() + +operator fun Date.minus(other: Date): Date = Date(this@minus.time - other.time) + +/** + * Date 의 시, 분, 초를 0 으로 설정한다 + */ +fun Date.adjust(): Date = Calendar.getInstance().apply { + time = this@adjust + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) +}.time diff --git a/domain/src/test/java/com/lighthouse/domain/usecase/GetBrandPlaceInfosUseCaseTest.kt b/domain/src/test/java/com/lighthouse/domain/usecase/GetBrandPlaceInfosUseCaseTest.kt new file mode 100644 index 000000000..cb13d5733 --- /dev/null +++ b/domain/src/test/java/com/lighthouse/domain/usecase/GetBrandPlaceInfosUseCaseTest.kt @@ -0,0 +1,58 @@ +package com.lighthouse.domain.usecase + +import com.google.common.truth.Truth +import com.lighthouse.domain.DmsLocation +import com.lighthouse.domain.LocationConverter +import com.lighthouse.domain.model.BrandPlaceInfo +import com.lighthouse.domain.repository.BrandRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.DisplayName + +@ExperimentalCoroutinesApi +class GetBrandPlaceInfosUseCaseTest { + + private val brandRepository: BrandRepository = mockk() + private lateinit var cardinalLocations: List + + @Before + fun setUp() { + cardinalLocations = LocationConverter.getCardinalDirections(x, y) + } + + @Test + @DisplayName("[성공] 검색 결과를 갖고온다") + fun getBrandPlaceInfoSuccess() = runTest { + // given + val useCase = GetBrandPlaceInfosUseCase(brandRepository) + cardinalLocations.forEach { location -> + brandKeyword.forEach { brandName -> + coEvery { + brandRepository.getBrandPlaceInfo(brandName, location.x, location.y, 5) + } returns Result.success(brandResult) + } + } + + // when + val action = useCase(brandKeyword, x, y, 5).getOrThrow() + + // then + for (brandPlaceInfo in action) { + Truth.assertThat(brandPlaceInfo).isEqualTo(brandPlaceInfoResults) + } + } + + companion object { + private const val x = 37.284 + private const val y = 127.1071 + private val brandKeyword = listOf("스타벅스", "베스킨라빈스", "BHC", "BBQ", "GS25", "CU", "아파트", "어린이집", "파파존스") + private val brandResult = brandKeyword.map { + BrandPlaceInfo("서울 중구", "스타벅스", "", "", "", "", "") + } + private val brandPlaceInfoResults = BrandPlaceInfo("서울 중구", "스타벅스", "", "", "", "", "") + } +} diff --git a/gradle.properties b/gradle.properties index 3c5031eb7..00c0450a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,9 +15,10 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true diff --git a/presentation/.gitignore b/presentation/.gitignore index 42afabfd2..2abde4aab 100644 --- a/presentation/.gitignore +++ b/presentation/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/google-services.json diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 397f23c4b..f2204c558 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -1,6 +1,14 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { id("com.android.library") + id("kotlin-parcelize") id("org.jetbrains.kotlin.android") + id("dagger.hilt.android.plugin") + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") + kotlin("kapt") + kotlin("plugin.serialization") version "1.5.0" } android { @@ -12,6 +20,7 @@ android { targetSdk = AppConfig.targetSdk testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["runnerBuilder"] = "de.mannodermaus.junit5.AndroidJUnit5Builder" } buildTypes { @@ -22,23 +31,56 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = AppConfig.jvmTarget } buildFeatures { dataBinding = true + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Versions.KOTLIN_COMPILER_EXTENSION + } + + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } } } dependencies { implementation(project(":domain")) + implementation(platform(Libraries.FIREBASE_BOM)) + implementation(platform(Libraries.COMPOSE_BOM)) implementation(Libraries.VIEW_LIBRARIES) - implementation(TestImpl.TEST_LIBRARIES) - androidTestImplementation(AndroidTestImpl.ANDROID_LIBRARIES) + testImplementation(TestImpl.TEST_LIBRARIES) + kapt(Kapt.VIEW_LIBRARIES) + debugImplementation(DebugImpl.VIEW_LIBRARIES) + androidTestImplementation(AndroidTestImpl.VIEW_LIBRARIES) + annotationProcessor(AnnotationProcessors.VIEW_LIBRARIES) +} + +kapt { + correctErrorTypes = true +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = "1.8" + } +} + +// JUnit5 +tasks.withType { + useJUnitPlatform() } diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 5d8774047..d41c6a205 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -1,20 +1,75 @@ - - + + + + + + + android:noHistory="true" + android:theme="@style/Theme.BEEP.Splash"> + + + + + + + + + + + + + + + - + android:name="android.appwidget.provider" + android:resource="@xml/gifticon_widget_provider" /> + + + + + \ No newline at end of file diff --git a/presentation/src/main/ic_launcher-playstore.png b/presentation/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..73b343e73 Binary files /dev/null and b/presentation/src/main/ic_launcher-playstore.png differ diff --git a/presentation/src/main/java/com/lighthouse/presentation/MainActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/MainActivity.kt deleted file mode 100644 index 87d7f26ff..000000000 --- a/presentation/src/main/java/com/lighthouse/presentation/MainActivity.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.lighthouse.presentation - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } -} diff --git a/presentation/src/main/java/com/lighthouse/presentation/adapter/BindableAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/adapter/BindableAdapter.kt new file mode 100644 index 000000000..93eac5362 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/adapter/BindableAdapter.kt @@ -0,0 +1,6 @@ +package com.lighthouse.presentation.adapter + +interface BindableAdapter { + + fun setData(data: T, commitCallback: () -> Unit = {}) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/adapter/BindableListAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/adapter/BindableListAdapter.kt new file mode 100644 index 000000000..cfbdcd5b0 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/adapter/BindableListAdapter.kt @@ -0,0 +1,14 @@ +package com.lighthouse.presentation.adapter + +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView + +abstract class BindableListAdapter(diffCallback: DiffUtil.ItemCallback) : + ListAdapter(diffCallback), + BindableAdapter> { + + override fun setData(data: List, commitCallback: () -> Unit) { + submitList(data, commitCallback) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/adapter/BindablePagingAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/adapter/BindablePagingAdapter.kt new file mode 100644 index 000000000..c4204c268 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/adapter/BindablePagingAdapter.kt @@ -0,0 +1,31 @@ +package com.lighthouse.presentation.adapter + +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +abstract class BindablePagingAdapter( + diffCallback: DiffUtil.ItemCallback +) : PagingDataAdapter(diffCallback), BindableAdapter> { + + private val scope = CoroutineScope(Dispatchers.Main) + private var job: Job? = null + + override fun setData(data: PagingData, commitCallback: () -> Unit) { + job?.cancel() + job = scope.launch { + submitData(data) + commitCallback() + } + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + job?.cancel() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/background/BeepWorkManager.kt b/presentation/src/main/java/com/lighthouse/presentation/background/BeepWorkManager.kt new file mode 100644 index 000000000..22bcf7f11 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/background/BeepWorkManager.kt @@ -0,0 +1,84 @@ +package com.lighthouse.presentation.background + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.lighthouse.presentation.ui.widget.BeepWidgetWorker +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit + +class BeepWorkManager( + @ApplicationContext context: Context +) { + + private val workPolicyKeep = ExistingPeriodicWorkPolicy.KEEP + private val workPolicyReplace = ExistingPeriodicWorkPolicy.REPLACE + + private val notificationWorkRequest: PeriodicWorkRequest = + PeriodicWorkRequestBuilder(NOTIFICATION_INTERVAL, TimeUnit.HOURS).build() + + private val widgetWorkRequest: PeriodicWorkRequest = + PeriodicWorkRequestBuilder(WIDGET_INTERVAL, TimeUnit.MINUTES).build() + + private val manager = WorkManager.getInstance(context).also { + it.enqueueUniquePeriodicWork( + NOTIFICATION_WORK_NAME, + workPolicyKeep, + notificationWorkRequest + ) + } + + /** + * 새로고침이나 시작할때 실행할 함수 + * @param force 만약 새로고침 + */ + fun widgetEnqueue(force: Boolean = false) { + val policy = when (force) { + true -> workPolicyReplace + false -> workPolicyKeep + } + manager.enqueueUniquePeriodicWork( + WIDGET_WORK_NAME, + policy, + widgetWorkRequest + ) + } + + /** + * WidgetReceiver에서 onDisabled때 실행할 함수 + */ + fun widgetCancel() { + manager.cancelUniqueWork(WIDGET_WORK_NAME) + } + + fun notificationCancel() { + manager.cancelUniqueWork(NOTIFICATION_WORK_NAME) + } + + fun notificationEnqueue() { + manager.enqueueUniquePeriodicWork( + NOTIFICATION_WORK_NAME, + workPolicyKeep, + notificationWorkRequest + ) + } + + companion object { + private const val NOTIFICATION_WORK_NAME = "notification" + private const val NOTIFICATION_INTERVAL = 24L + private const val WIDGET_WORK_NAME = "widget" + private const val WIDGET_INTERVAL = 15L + + @Volatile + private var instance: BeepWorkManager? = null + + fun getInstance(context: Context): BeepWorkManager = + instance ?: synchronized(this) { + BeepWorkManager(context).also { + instance = it + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/background/NotificationHelper.kt b/presentation/src/main/java/com/lighthouse/presentation/background/NotificationHelper.kt new file mode 100644 index 000000000..4dfbaa3b0 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/background/NotificationHelper.kt @@ -0,0 +1,80 @@ +package com.lighthouse.presentation.background + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.TaskStackBuilder +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.detailgifticon.GifticonDetailActivity +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class NotificationHelper @Inject constructor( + @ApplicationContext private val context: Context +) { + private val notificationManager = NotificationManagerCompat.from(context) + + private val channelDescription = context.getString(R.string.notification_channel_description) + private val channelName = "기프티콘 만료 알림" + private val channelGroup = "BEEP" + + init { + createNotificationChannel() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val name = channelName + val descriptionText = channelDescription + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(Extras.NOTIFICATION_CHANNEL, name, importance).apply { + description = descriptionText + enableVibration(true) + } + + val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + } + + fun applyNotification(gifticon: Gifticon, remainDays: Int) { + val intent = Intent(context, GifticonDetailActivity::class.java).apply { + putExtra(Extras.KEY_GIFTICON_ID, gifticon.id) + } + + val code = gifticon.barcode.hashCode() + + val gifticonDetailPendingIntent = TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(intent) + getPendingIntent(code, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + } + + val builder = NotificationCompat.Builder(context, Extras.NOTIFICATION_CHANNEL) + .setSmallIcon(R.drawable.ic_splash_beep) + .setContentTitle(context.getString(R.string.notification_title)) + .setContentText( + String.format( + context.getString(R.string.notification_description), + gifticon.name, + remainDays + ) + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(gifticonDetailPendingIntent) + .setGroup(channelGroup) + + val notification = builder.build().apply { + flags = Notification.FLAG_AUTO_CANCEL + } + notificationManager.notify(code, notification) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/background/NotificationWorker.kt b/presentation/src/main/java/com/lighthouse/presentation/background/NotificationWorker.kt new file mode 100644 index 000000000..c77b53eb3 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/background/NotificationWorker.kt @@ -0,0 +1,48 @@ +package com.lighthouse.presentation.background + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.usecase.GetGifticonsUseCase +import com.lighthouse.presentation.util.TimeCalculator +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class NotificationWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val notificationHelper: NotificationHelper, + private val getGifticonsUseCase: GetGifticonsUseCase +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + getNotificationGifticons() + return Result.success() + } + + private suspend fun getNotificationGifticons() { + getGifticonsUseCase.getUsableGifticons().collect { dbResult -> + if (dbResult is DbResult.Success) { + val usableGifticons = dbResult.data + + targetDDays.forEach { days -> + val targetGifticons = + usableGifticons.filter { TimeCalculator.formatDdayToInt(it.expireAt.time) == days } + createNotification(targetGifticons, days) + } + } + } + } + + private fun createNotification(list: List, remainDays: Int) { + list.forEach { notificationHelper.applyNotification(it, remainDays) } + } + + companion object { + private val targetDDays = listOf(3, 7, 14) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/binding/ImageView.kt b/presentation/src/main/java/com/lighthouse/presentation/binding/ImageView.kt new file mode 100644 index 000000000..b8501e54a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/binding/ImageView.kt @@ -0,0 +1,75 @@ +package com.lighthouse.presentation.binding + +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.PorterDuff +import android.net.Uri +import android.widget.ImageView +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy + +@BindingAdapter("loadUri") +fun ImageView.loadUri(uri: Uri?) { + setImageBitmap(null) + if (uri != null) { + Glide.with(this) + .load(uri) + .into(this) + } else { + setImageBitmap(null) + } +} + +@BindingAdapter("loadUriWithoutCache") +fun ImageView.loadUriWithoutCache(uri: Uri?) { + setImageBitmap(null) + if (uri != null) { + Glide.with(this) + .load(uri) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .into(this) + } else { + setImageBitmap(null) + } +} + +@BindingAdapter("filterToGray") +fun ImageView.applyFilterGray(applyFilter: Boolean = true) { + if (applyFilter) { + setColorFilter(Color.parseColor("#55000000"), PorterDuff.Mode.DARKEN) + } else { + clearColorFilter() + } +} + +@BindingAdapter("loadWithFileStreamPath") +fun ImageView.loadWithFileStreamPath(filename: String?) { + setImageBitmap(null) + if (filename != null) { + val file = context.getFileStreamPath(filename) + Glide.with(this) + .load(file) + .into(this) + } else { + setImageBitmap(null) + } +} + +@BindingAdapter("setImageRes") +fun setImageRes(view: ImageView, @DrawableRes resId: Int?) { + if (resId != null) { + view.setImageResource(resId) + } else { + view.setImageBitmap(null) + } +} + +@BindingAdapter("setTintRes") +fun setTintRes(view: ImageView, @ColorRes resId: Int?) { + resId ?: return + view.imageTintList = ColorStateList.valueOf(view.context.getColor(resId)) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/binding/Layout.kt b/presentation/src/main/java/com/lighthouse/presentation/binding/Layout.kt new file mode 100644 index 000000000..75a8786dc --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/binding/Layout.kt @@ -0,0 +1,15 @@ +package com.lighthouse.presentation.binding + +import androidx.core.view.isVisible +import androidx.databinding.BindingAdapter +import com.facebook.shimmer.ShimmerFrameLayout + +@BindingAdapter("setShimmerState") +fun setShimmerState(layout: ShimmerFrameLayout, state: Boolean) { + layout.isVisible = state + + when (state) { + true -> layout.startShimmer() + false -> layout.stopShimmer() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/binding/RecyclerView.kt b/presentation/src/main/java/com/lighthouse/presentation/binding/RecyclerView.kt new file mode 100644 index 000000000..a9129cee7 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/binding/RecyclerView.kt @@ -0,0 +1,30 @@ +package com.lighthouse.presentation.binding + +import androidx.databinding.BindingAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewpager2.widget.ViewPager2 +import com.lighthouse.presentation.adapter.BindableAdapter + +@BindingAdapter("setItems") +fun setItems(view: RecyclerView, data: T?) { + data ?: return + when (val listAdapter = view.adapter) { + is BindableAdapter<*> -> { + (listAdapter as BindableAdapter).setData(data) { + view.invalidateItemDecorations() + } + } + } +} + +@BindingAdapter("setViewPagerItems") +fun setViewPagerItems(view: ViewPager2, data: T?) { + data ?: return + when (val listAdapter = view.adapter) { + is BindableAdapter<*> -> { + (listAdapter as BindableAdapter).setData(data) { + view.invalidateItemDecorations() + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/binding/TextInputLayout.kt b/presentation/src/main/java/com/lighthouse/presentation/binding/TextInputLayout.kt new file mode 100644 index 000000000..0f465c8b1 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/binding/TextInputLayout.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.binding + +import androidx.databinding.BindingAdapter +import com.google.android.material.textfield.TextInputLayout +import com.lighthouse.presentation.R + +@BindingAdapter("concurrencySuffixText") +fun applySuffixTextOrNull(view: TextInputLayout, text: String) { + view.suffixText = if (text.isNotBlank()) view.context.getString(R.string.all_cash_origin_unit) else null +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/binding/TextView.kt b/presentation/src/main/java/com/lighthouse/presentation/binding/TextView.kt new file mode 100644 index 000000000..bd983da28 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/binding/TextView.kt @@ -0,0 +1,103 @@ +package com.lighthouse.presentation.binding + +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.UnderlineSpan +import android.view.View +import android.widget.TextView +import androidx.databinding.BindingAdapter +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.toDayOfMonth +import com.lighthouse.presentation.extension.toMonth +import com.lighthouse.presentation.extension.toYear +import com.lighthouse.presentation.util.TimeCalculator +import com.lighthouse.presentation.util.resource.UIText +import java.text.DecimalFormat +import java.util.Date + +@BindingAdapter("dateFormat") +fun applyDateFormat(view: TextView, date: Date?) { + date ?: return + view.text = view.context.getString(R.string.all_date, date.toYear(), date.toMonth(), date.toDayOfMonth()) +} + +@BindingAdapter("concurrencyFormat") +fun applyConcurrencyFormat(view: TextView, amount: Int) { + val format = view.context.resources.getString(R.string.all_concurrency_format) + val formattedNumber = DecimalFormat(format).format(amount) + view.setText(if (amount > 0) view.context.resources.getString(R.string.all_cash_unit, formattedNumber) else "") +} + +@BindingAdapter("setUIText") +fun TextView.setUIText(uiText: UIText?) { + text = uiText?.asString(context) ?: "" +} + +/** + * 타겟 텍스트에 밑줄을 칠한다. 타겟 텍스트를 찾지 못 한 경우 무시된다 + * + * @param targetText 밑줄을 칠할 텍스트 + */ +@BindingAdapter("underLineText") +fun applyUnderLine(view: TextView, targetText: String) { + val start = view.text.indexOf(string = targetText, ignoreCase = false) + if (start == -1) return + val end = start + targetText.length + + view.text = SpannableStringBuilder(view.text).apply { + setSpan(UnderlineSpan(), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } +} + +/** + * 부분적으로 클릭할 수 있다. 타겟 텍스트를 찾지 못 한 경우 무시된다 + * + * @param targetText 클릭 가능한 문구 + * @param onClickListener 클릭 리스너 + * @param drawUnderLine 텍스트 강조 여부 + */ +@BindingAdapter("clickableText", "clickableClicked", "drawUnderLine", requireAll = false) +fun TextView.applyClickable(targetText: String, onClickListener: View.OnClickListener, drawUnderLine: Boolean? = null) { + val start = text.indexOf(string = targetText, ignoreCase = false) + + if (start == -1) return + val end = start + targetText.length + + val spannableString = SpannableString(text).apply { + setSpan( + object : ClickableSpan() { + override fun onClick(widget: View) { + onClickListener.onClick(widget) + } + + override fun updateDrawState(ds: TextPaint) { + if (drawUnderLine != null && drawUnderLine == false) return + super.updateDrawState(ds) + } + }, + start, + end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + movementMethod = LinkMovementMethod() + setText(spannableString, TextView.BufferType.SPANNABLE) +} + +@BindingAdapter("setDday") +fun setDday(view: TextView, date: Date) { + val dDay = TimeCalculator.formatDdayToInt(date.time) + view.text = when { + dDay == TimeCalculator.MIN_DAY -> view.context.getString(R.string.all_d_very_day) + dDay in TimeCalculator.MIN_DAY until TimeCalculator.MAX_DAY -> String.format( + view.context.getString(R.string.all_d_day), + dDay + ) + dDay < TimeCalculator.MIN_DAY -> view.context.getString(R.string.all_d_day_expired) + else -> view.context.getString(R.string.all_d_day_more_than_year) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/binding/View.kt b/presentation/src/main/java/com/lighthouse/presentation/binding/View.kt new file mode 100644 index 000000000..fa45149d5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/binding/View.kt @@ -0,0 +1,65 @@ +package com.lighthouse.presentation.binding + +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.databinding.BindingAdapter +import com.lighthouse.presentation.util.OnThrottleClickListener +import com.lighthouse.presentation.util.resource.AnimInfo + +@BindingAdapter("isVisible") +fun applyVisibility(view: View, visible: Boolean) { + view.visibility = if (visible) View.VISIBLE else View.GONE +} + +@BindingAdapter("onThrottleClickListener") +fun View.setOnThrottleClickListener(listener: (View) -> Unit) { + setOnClickListener(object : OnThrottleClickListener() { + override fun onThrottleClick(view: View) { + listener(view) + } + }) +} + +@BindingAdapter(value = ["animation", "animationCondition"], requireAll = false) +fun View.playAnimation(animation: Animation, condition: Boolean? = null) { + if (condition != false) { + startAnimation(animation) + } else { + clearAnimation() + } +} + +@BindingAdapter("animInfo") +fun View.playAnimationByAnimInfo(animInfo: AnimInfo?) { + animInfo ?: return + + when (animInfo) { + is AnimInfo.Empty -> return + is AnimInfo.AnimResource -> { + if (animInfo.condition) { + startAnimation(AnimationUtils.loadAnimation(context, animInfo.resId)) + } else { + clearAnimation() + } + } + is AnimInfo.DynamicAnim -> { + if (animInfo.condition) { + startAnimation(animInfo.animation) + } else { + clearAnimation() + } + } + } +} + +@BindingAdapter(value = ["isAnimatedVisible", "visibleAnimation", "goneAnimation"], requireAll = false) +fun View.playVisibilityAnimation(visible: Boolean, visibleAnimation: Animation?, goneAnimation: Animation?) { + if (visible) { + visibility = View.VISIBLE + visibleAnimation?.let { startAnimation(it) } + } else { + goneAnimation?.let { startAnimation(it) } + visibility = View.GONE + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/Bitmap.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/Bitmap.kt new file mode 100644 index 000000000..2ad5c50ea --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/Bitmap.kt @@ -0,0 +1,9 @@ +package com.lighthouse.presentation.extension + +import android.graphics.Bitmap +import android.graphics.Matrix + +fun Bitmap.rotated(degrees: Float): Bitmap { + val matrix = Matrix().apply { postRotate(degrees) } + return Bitmap.createBitmap(this, 0, 0, width, height, matrix, false) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/Bundle.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/Bundle.kt new file mode 100644 index 000000000..3f51b61ed --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/Bundle.kt @@ -0,0 +1,21 @@ +package com.lighthouse.presentation.extension + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable + +fun Bundle.getParcelableArrayListCompat(key: String, clazz: Class): ArrayList? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableArrayList(key, clazz) + } else { + getParcelableArrayList(key) + } +} + +fun Bundle.getParcelableCompat(key: String, clazz: Class): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(key, clazz) + } else { + getParcelable(key) as? T + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/ContentResolver.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/ContentResolver.kt new file mode 100644 index 000000000..1e2bdf181 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/ContentResolver.kt @@ -0,0 +1,28 @@ +package com.lighthouse.presentation.extension + +import android.content.ContentResolver +import android.content.ContentResolver.SCHEME_CONTENT +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import java.io.IOException + +fun ContentResolver.getBitmap(uri: Uri): Bitmap? { + if (uri.scheme != SCHEME_CONTENT) { + return null + } + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val source = ImageDecoder.createSource(this, uri) + ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.isMutableRequired = true + } + } else { + MediaStore.Images.Media.getBitmap(this, uri) + } + } catch (e: IOException) { + null + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/Date.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/Date.kt new file mode 100644 index 000000000..c895aa6d9 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/Date.kt @@ -0,0 +1,51 @@ +package com.lighthouse.presentation.extension + +import android.content.Context +import com.lighthouse.domain.util.isExpired +import com.lighthouse.presentation.R +import com.lighthouse.presentation.util.TimeCalculator +import com.lighthouse.presentation.util.TimeCalculator.MAX_DAY +import com.lighthouse.presentation.util.TimeCalculator.MIN_DAY +import com.lighthouse.presentation.util.resource.UIText +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +fun Date.toYear(): Int { + return SimpleDateFormat("yyyy", Locale.getDefault()).format(this).toInt() +} + +fun Date.toMonth(): Int { + return SimpleDateFormat("M", Locale.getDefault()).format(this).toInt() +} + +fun Date.toDayOfMonth(): Int { + return SimpleDateFormat("d", Locale.getDefault()).format(this).toInt() +} + +fun Date.toDday(context: Context): String { + val dDay = TimeCalculator.formatDdayToInt(time) + return when { + dDay == MIN_DAY -> context.getString(R.string.all_d_very_day) + isExpired() -> context.getString(R.string.all_d_day_expired) + dDay in MIN_DAY + 1 until MAX_DAY -> String.format( + context.getString(R.string.all_d_day), + dDay + ) + else -> context.getString(R.string.all_d_day_more_than_year) + } +} + +fun Date.toExpireDate(context: Context): String { + return UIText.StringResource( + R.string.all_expired_date, + toYear(), + toMonth(), + toDayOfMonth() + ).asString(context) +} + +fun Date.toString(pattern: String): String { + val format = SimpleDateFormat(pattern, Locale.getDefault()) + return format.format(this) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/DialogFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/DialogFragment.kt new file mode 100644 index 000000000..eb3b937d5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/DialogFragment.kt @@ -0,0 +1,8 @@ +package com.lighthouse.presentation.extension + +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager + +fun DialogFragment.show(fragmentManager: FragmentManager) { + show(fragmentManager, javaClass.name) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/Intent.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/Intent.kt new file mode 100644 index 000000000..48f709882 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/Intent.kt @@ -0,0 +1,12 @@ +package com.lighthouse.presentation.extension + +import android.content.Intent +import android.os.Parcelable + +fun Intent.getParcelableArrayList(key: String, clazz: Class): ArrayList? { + return extras?.getParcelableArrayListCompat(key, clazz) +} + +fun Intent.getParcelable(key: String, clazz: Class): T? { + return extras?.getParcelableCompat(key, clazz) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/KotlinExtention.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/KotlinExtention.kt new file mode 100644 index 000000000..6ad03c66b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/KotlinExtention.kt @@ -0,0 +1,66 @@ +package com.lighthouse.presentation.extension + +import android.content.Context +import android.content.res.Resources +import android.util.TypedValue +import com.lighthouse.presentation.R +import java.text.DecimalFormat +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +val Int.dp + get() = Resources.getSystem().displayMetrics?.let { dm -> + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), dm) + } ?: 0f + +val Int.dpToPx + get() = Resources.getSystem().displayMetrics?.let { dm -> + this * dm.density + } ?: 0f + +val Float.dpToPx + get() = Resources.getSystem().displayMetrics?.let { dm -> + this * dm.density + } ?: 0f + +fun Int.toConcurrency(context: Context, useUnit: Boolean = true): String { + val format = context.resources.getString(R.string.all_concurrency_format) + val formattedNumber = DecimalFormat(format).format(this) + return if (useUnit) { + context.resources.getString(R.string.all_cash_unit, formattedNumber) + } else { + formattedNumber + } +} + +fun Int.toConcurrency(): String { + return DecimalFormat("#,###").format(this) +} + +val screenWidth: Int + get() = Resources.getSystem().displayMetrics?.widthPixels ?: 0 + +val screenHeight: Int + get() = Resources.getSystem().displayMetrics?.heightPixels ?: 0 + +fun String.toDigit(): Int { + return filter { it.isDigit() }.toIntOrNull() ?: 0 +} + +fun String.toNumber(): String { + return (filter { it.isDigit() }.toLongOrNull() ?: 0L).toString() +} + +fun String.toNumberFormat(): String { + return toNumber() + .reversed() + .chunked(3) + .joinToString(",") + .reversed() +} + +fun String.toDate(pattern: String): Date { + val format = SimpleDateFormat(pattern, Locale.getDefault()) + return format.parse(this) ?: Date(0) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/LifecycleOwner.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/LifecycleOwner.kt new file mode 100644 index 000000000..74c86906d --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/LifecycleOwner.kt @@ -0,0 +1,16 @@ +package com.lighthouse.presentation.extension + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +fun LifecycleOwner.repeatOnStarted(block: suspend (CoroutineScope) -> Unit) { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + block(this) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/ScrollView.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/ScrollView.kt new file mode 100644 index 000000000..b87f8919e --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/ScrollView.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.extension + +import android.view.View +import android.widget.ScrollView + +fun ScrollView.scrollToBottom() { + this.post { + this.fullScroll(View.FOCUS_DOWN) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extension/View.kt b/presentation/src/main/java/com/lighthouse/presentation/extension/View.kt new file mode 100644 index 000000000..039797288 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extension/View.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.extension + +import android.view.View + +fun View.isOnScreen(): Boolean { + val viewLocation = IntArray(2) + getLocationOnScreen(viewLocation) + + return viewLocation[0] in 0..screenWidth && viewLocation[1] in 0..screenHeight +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/extra/Extras.kt b/presentation/src/main/java/com/lighthouse/presentation/extra/Extras.kt new file mode 100644 index 000000000..d3d951767 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/extra/Extras.kt @@ -0,0 +1,37 @@ +package com.lighthouse.presentation.extra + +object Extras { + + const val KEY_SELECTED_GALLERY_ITEM = "Extra.SelectedGalleryItem" + const val KEY_ORIGIN_IMAGE = "Extra.OriginImage" + const val KEY_BARCODE = "Extra.Barcode" + + const val KEY_ENABLE_ASPECT_RATIO = "Extra.EnableAspectRatio" + const val KEY_ASPECT_RATIO = "Extra.AspectRatio" + const val KEY_CROPPED_IMAGE = "Extra.CroppedImage" + const val KEY_CROPPED_RECT = "Extra.CroppedRect" + + const val KEY_MODIFY_GIFTICON_ID = "Extra.ModifyGifticon.GifticonId" + + const val KEY_GIFTICON_ID = "Extra.GifticonDetail.KeyGifticonId" + + const val KEY_NEAR_BRANDS = "Extra.Home.Brands" + const val KEY_NEAR_GIFTICONS = "Extra.Home.Gifticons" + + const val KEY_PIN_REVISE = "Extra.Pin.Revise" + + const val NOTIFICATION_CHANNEL = "Extra.Notification.Channel" + + const val CATEGORY_MART = "대형마트" + const val CATEGORY_CONVENIENCE = "편의점" + const val CATEGORY_CULTURE = "문화시설" + const val CATEGORY_ACCOMMODATION = "숙박" + const val CATEGORY_RESTAURANT = "음식점" + const val CATEGORY_CAFE = "카페" + + const val KEY_WIDGET_BRAND = "Extra.Widget.Item.id" + const val KEY_WIDGET_EVENT = "Extra.Widget.Event" + const val WIDGET_EVENT_MAP = "gotoMap" + + const val TAG_DETAIL_SETTING = "Extra.Detail.Setting" +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/AddGifticonUIModelMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/AddGifticonUIModelMapper.kt new file mode 100644 index 000000000..da3f0d360 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/AddGifticonUIModelMapper.kt @@ -0,0 +1,29 @@ +package com.lighthouse.presentation.mapper + +import androidx.core.graphics.toRect +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.presentation.model.AddGifticonUIModel +import com.lighthouse.presentation.model.GalleryUIModel + +fun AddGifticonUIModel.toGalleryUIModel(order: Int): GalleryUIModel.Gallery = GalleryUIModel.Gallery( + id = id, + uri = origin, + selectedOrder = order, + createdDate = createdDate +) + +fun AddGifticonUIModel.toDomain(): GifticonForAddition { + return GifticonForAddition( + hasImage = hasImage, + name = name, + brandName = brandName, + barcode = barcode, + expiredAt = expiredAt, + isCashCard = isCashCard, + balance = balance.toIntOrNull() ?: 0, + memo = memo, + originUri = origin.toString(), + tempCroppedUri = gifticonImage.uri?.toString() ?: "", + croppedRect = gifticonImage.croppedRect.toRect().toDomain() + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/BrandPlaceInfoUiModelMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/BrandPlaceInfoUiModelMapper.kt new file mode 100644 index 000000000..9a64345d1 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/BrandPlaceInfoUiModelMapper.kt @@ -0,0 +1,16 @@ +package com.lighthouse.presentation.mapper + +import com.lighthouse.domain.model.BrandPlaceInfo +import com.lighthouse.presentation.model.BrandPlaceInfoUiModel + +fun List.toPresentation(): List = map { + BrandPlaceInfoUiModel( + addressName = it.addressName, + placeName = it.placeName, + categoryName = it.categoryName, + placeUrl = it.placeUrl, + brand = it.brand, + x = it.x, + y = it.y + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/GalleryImageMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/GalleryImageMapper.kt new file mode 100644 index 000000000..1b869057a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/GalleryImageMapper.kt @@ -0,0 +1,13 @@ +package com.lighthouse.presentation.mapper + +import android.net.Uri +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.presentation.extension.toString +import com.lighthouse.presentation.model.GalleryUIModel + +fun GalleryImage.toPresentation(index: Int = -1): GalleryUIModel.Gallery = GalleryUIModel.Gallery( + id = id, + uri = Uri.parse(contentUri), + selectedOrder = index, + createdDate = date.toString("yyyy-MM-dd") +) diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/GalleryUIModelMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/GalleryUIModelMapper.kt new file mode 100644 index 000000000..213a861dc --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/GalleryUIModelMapper.kt @@ -0,0 +1,52 @@ +package com.lighthouse.presentation.mapper + +import android.graphics.RectF +import com.lighthouse.domain.model.GalleryImage +import com.lighthouse.presentation.extension.toDate +import com.lighthouse.presentation.model.AddGifticonUIModel +import com.lighthouse.presentation.model.CroppedImage +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.ui.edit.addgifticon.adapter.AddGifticonItemUIModel +import java.util.Date + +fun GalleryUIModel.Gallery.toAddGifticonItemUIModel(): AddGifticonItemUIModel.Gifticon { + return AddGifticonItemUIModel.Gifticon( + id = id, + origin = uri, + thumbnailImage = CroppedImage(), + isSelected = false, + isDelete = false, + isValid = false + ) +} + +fun GalleryUIModel.Gallery.toAddGifticonUIModel(): AddGifticonUIModel { + return AddGifticonUIModel( + id = id, + origin = uri, + hasImage = true, + name = "", + nameRectF = RectF(), + brandName = "", + brandNameRectF = RectF(), + approveBrandName = "", + barcode = "", + barcodeRectF = RectF(), + expiredAt = Date(0), + expiredAtRectF = RectF(), + approveExpiredAt = false, + isCashCard = false, + balance = "", + balanceRectF = RectF(), + memo = "", + gifticonImage = CroppedImage(), + approveGifticonImage = false, + createdDate = createdDate + ) +} + +fun GalleryUIModel.Gallery.toDomain(): GalleryImage = GalleryImage( + id = id, + contentUri = uri.toString(), + date = createdDate.toDate("yyyy-MM-dd") +) diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonForAdditionMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonForAdditionMapper.kt new file mode 100644 index 000000000..d2100aa61 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonForAdditionMapper.kt @@ -0,0 +1,40 @@ +package com.lighthouse.presentation.mapper + +import android.graphics.RectF +import android.net.Uri +import androidx.core.graphics.toRectF +import com.lighthouse.domain.model.GifticonForAddition +import com.lighthouse.presentation.model.AddGifticonUIModel +import com.lighthouse.presentation.model.CroppedImage + +fun GifticonForAddition.toPresentation( + id: Long, + createdDate: String, + approveBrandName: String = "" +): AddGifticonUIModel { + return AddGifticonUIModel( + id = id, + hasImage = hasImage, + name = name, + nameRectF = RectF(), + brandName = brandName, + brandNameRectF = RectF(), + approveBrandName = approveBrandName, + barcode = barcode, + barcodeRectF = RectF(), + expiredAt = expiredAt, + expiredAtRectF = RectF(), + approveExpiredAt = false, + isCashCard = isCashCard, + balance = balance.toString(), + balanceRectF = RectF(), + memo = memo, + origin = Uri.parse(originUri), + gifticonImage = CroppedImage( + uri = if (tempCroppedUri.isNotEmpty()) Uri.parse(tempCroppedUri) else null, + croppedRect = croppedRect.toPresentation().toRectF() + ), + approveGifticonImage = false, + createdDate = createdDate + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonForUpdateMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonForUpdateMapper.kt new file mode 100644 index 000000000..c01630808 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonForUpdateMapper.kt @@ -0,0 +1,34 @@ +package com.lighthouse.presentation.mapper + +import android.graphics.RectF +import android.net.Uri +import androidx.core.graphics.toRectF +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.presentation.model.ModifyGifticonUIModel + +fun GifticonForUpdate.toPresentation(): ModifyGifticonUIModel { + return ModifyGifticonUIModel( + id = id, + userId = userId, + hasImage = hasImage, + oldCroppedUri = Uri.parse(oldCroppedUri), + croppedUri = Uri.parse(croppedUri), + croppedRect = croppedRect.toPresentation().toRectF(), + name = name, + nameRectF = RectF(), + brandName = brandName, + brandNameRectF = RectF(), + approveBrandName = brandName, + barcode = barcode, + barcodeRectF = RectF(), + expiredAt = expiredAt, + expiredAtRectF = RectF(), + approveExpiredAt = false, + isCashCard = isCashCard, + balance = balance.toString(), + balanceRectF = RectF(), + memo = memo, + isUsed = isUsed, + createdAt = createdAt + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonMapper.kt new file mode 100644 index 000000000..d15a44651 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonMapper.kt @@ -0,0 +1,37 @@ +package com.lighthouse.presentation.mapper + +import android.net.Uri +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.presentation.model.GifticonUIModel +import com.lighthouse.presentation.model.GifticonWithDistanceUIModel + +fun Gifticon.toPresentation(distance: Double): GifticonWithDistanceUIModel { + return GifticonWithDistanceUIModel( + id = id, + userId = userId, + hasImage = hasImage, + croppedUri = Uri.parse(croppedUri), + name = name, + brand = brand, + expireAt = expireAt, + balance = balance, + isUsed = isUsed, + distance = distance.toInt() + ) +} + +fun Gifticon.toPresentation(): GifticonUIModel { + return GifticonUIModel( + id = id, + hasImage = hasImage, + croppedUri = if (croppedUri.isNotEmpty()) Uri.parse(croppedUri) else null, + name = name, + brand = brand, + expireAt = expireAt, + barcode = barcode, + isCashCard = isCashCard, + balance = balance, + memo = memo, + isUsed = isUsed + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonSortByMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonSortByMapper.kt new file mode 100644 index 000000000..b939ded79 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/GifticonSortByMapper.kt @@ -0,0 +1,11 @@ +package com.lighthouse.presentation.mapper + +import com.lighthouse.domain.model.SortBy +import com.lighthouse.presentation.model.GifticonSortBy + +fun GifticonSortBy.toDomain(): SortBy { + return when (this) { + GifticonSortBy.RECENT -> SortBy.RECENT + GifticonSortBy.DEADLINE -> SortBy.DEADLINE + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/ModifyGifticonUIModelMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/ModifyGifticonUIModelMapper.kt new file mode 100644 index 000000000..8cbad0c3c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/ModifyGifticonUIModelMapper.kt @@ -0,0 +1,26 @@ +package com.lighthouse.presentation.mapper + +import androidx.core.graphics.toRect +import com.lighthouse.domain.model.GifticonForUpdate +import com.lighthouse.presentation.extension.toDigit +import com.lighthouse.presentation.model.ModifyGifticonUIModel + +fun ModifyGifticonUIModel.toDomain(): GifticonForUpdate { + return GifticonForUpdate( + id = id, + userId = userId, + hasImage = hasImage, + oldCroppedUri = oldCroppedUri.toString(), + croppedUri = croppedUri.toString(), + croppedRect = croppedRect.toRect().toDomain(), + name = name, + brandName = brandName, + barcode = barcode, + expiredAt = expiredAt, + isCashCard = isCashCard, + balance = balance.toDigit(), + memo = memo, + isUsed = isUsed, + createdAt = createdAt + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/RectMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/RectMapper.kt new file mode 100644 index 000000000..465c4358f --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/RectMapper.kt @@ -0,0 +1,8 @@ +package com.lighthouse.presentation.mapper + +import android.graphics.Rect +import com.lighthouse.domain.model.Rectangle + +fun Rect.toDomain(): Rectangle { + return Rectangle(left, top, right, bottom) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/mapper/RectangleMapper.kt b/presentation/src/main/java/com/lighthouse/presentation/mapper/RectangleMapper.kt new file mode 100644 index 000000000..1417d081e --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/mapper/RectangleMapper.kt @@ -0,0 +1,8 @@ +package com.lighthouse.presentation.mapper + +import android.graphics.Rect +import com.lighthouse.domain.model.Rectangle + +fun Rectangle.toPresentation(): Rect { + return Rect(left, top, right, bottom) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/AddGifticonUIModel.kt b/presentation/src/main/java/com/lighthouse/presentation/model/AddGifticonUIModel.kt new file mode 100644 index 000000000..cb3dea694 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/AddGifticonUIModel.kt @@ -0,0 +1,31 @@ +package com.lighthouse.presentation.model + +import android.graphics.RectF +import android.net.Uri +import java.util.Date + +data class AddGifticonUIModel( + val id: Long, + val origin: Uri, + val hasImage: Boolean, + val name: String, + val nameRectF: RectF, + val brandName: String, + val brandNameRectF: RectF, + val approveBrandName: String, + val barcode: String, + val barcodeRectF: RectF, + val expiredAt: Date, + val expiredAtRectF: RectF, + val approveExpiredAt: Boolean, + val isCashCard: Boolean, + val balance: String, + val balanceRectF: RectF, + val memo: String, + val gifticonImage: CroppedImage, + val approveGifticonImage: Boolean, + val createdDate: String +) { + val uri: Uri + get() = gifticonImage.uri ?: origin +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/BrandPlaceInfoUiModel.kt b/presentation/src/main/java/com/lighthouse/presentation/model/BrandPlaceInfoUiModel.kt new file mode 100644 index 000000000..8305526dc --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/BrandPlaceInfoUiModel.kt @@ -0,0 +1,16 @@ +package com.lighthouse.presentation.model + +import java.io.Serializable + +data class BrandPlaceInfoUiModel( + val addressName: String, + val placeName: String, + val categoryName: String, + val placeUrl: String, + val brand: String, + val x: String, + val y: String +) : Serializable { + + val brandLowerName = brand.lowercase() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/CashAmountPreset.kt b/presentation/src/main/java/com/lighthouse/presentation/model/CashAmountPreset.kt new file mode 100644 index 000000000..6b64950c7 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/CashAmountPreset.kt @@ -0,0 +1,14 @@ +package com.lighthouse.presentation.model + +import android.content.Context +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.toConcurrency + +enum class CashAmountPreset(val amount: Int?) { + ONE(1000), TWO(5000), THREE(10000), TOTAL(null); + + fun toString(context: Context): String { + return amount?.toConcurrency(context) + ?: context.resources.getString(R.string.use_gifticon_dialog_chip_total_amount) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/CroppedImage.kt b/presentation/src/main/java/com/lighthouse/presentation/model/CroppedImage.kt new file mode 100644 index 000000000..98447c2a0 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/CroppedImage.kt @@ -0,0 +1,9 @@ +package com.lighthouse.presentation.model + +import android.graphics.RectF +import android.net.Uri + +data class CroppedImage( + val uri: Uri? = null, + val croppedRect: RectF = RectF() +) diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/GalleryUIModel.kt b/presentation/src/main/java/com/lighthouse/presentation/model/GalleryUIModel.kt new file mode 100644 index 000000000..c2985a12b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/GalleryUIModel.kt @@ -0,0 +1,17 @@ +package com.lighthouse.presentation.model + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class GalleryUIModel { + data class Header(val date: String) : GalleryUIModel() + + @Parcelize + data class Gallery( + val id: Long, + val uri: Uri, + val selectedOrder: Int, + val createdDate: String + ) : GalleryUIModel(), Parcelable +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/GifticonSortBy.kt b/presentation/src/main/java/com/lighthouse/presentation/model/GifticonSortBy.kt new file mode 100644 index 000000000..1c75aa8ad --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/GifticonSortBy.kt @@ -0,0 +1,9 @@ +package com.lighthouse.presentation.model + +import androidx.annotation.StringRes +import com.lighthouse.presentation.R + +enum class GifticonSortBy(@StringRes val stringRes: Int) { + RECENT(R.string.gifticon_list_sort_by_recent), + DEADLINE(R.string.gifticon_list_sort_by_deadline) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/GifticonUIModel.kt b/presentation/src/main/java/com/lighthouse/presentation/model/GifticonUIModel.kt new file mode 100644 index 000000000..06aca9ef8 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/GifticonUIModel.kt @@ -0,0 +1,23 @@ +package com.lighthouse.presentation.model + +import android.net.Uri +import java.io.Serializable +import java.util.Date + +data class GifticonUIModel( + val id: String, + val hasImage: Boolean, + val croppedUri: Uri?, + val name: String, + val brand: String, + val expireAt: Date, + val barcode: String, + val isCashCard: Boolean, + val balance: Int, + val memo: String, + val isUsed: Boolean +) : Serializable { + + val originPath = "origin$id" + val brandLowerName = brand.lowercase() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/GifticonWithDistanceUIModel.kt b/presentation/src/main/java/com/lighthouse/presentation/model/GifticonWithDistanceUIModel.kt new file mode 100644 index 000000000..64182c432 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/GifticonWithDistanceUIModel.kt @@ -0,0 +1,17 @@ +package com.lighthouse.presentation.model + +import android.net.Uri +import java.util.Date + +data class GifticonWithDistanceUIModel( + val id: String, + val userId: String, + val hasImage: Boolean, + val croppedUri: Uri, + val name: String, + val brand: String, + val expireAt: Date, + val balance: Int, + val isUsed: Boolean, + val distance: Int +) diff --git a/presentation/src/main/java/com/lighthouse/presentation/model/ModifyGifticonUIModel.kt b/presentation/src/main/java/com/lighthouse/presentation/model/ModifyGifticonUIModel.kt new file mode 100644 index 000000000..3f2afbfd2 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/model/ModifyGifticonUIModel.kt @@ -0,0 +1,32 @@ +package com.lighthouse.presentation.model + +import android.graphics.RectF +import android.net.Uri +import java.util.Date + +data class ModifyGifticonUIModel( + val id: String, + val userId: String, + val hasImage: Boolean, + val oldCroppedUri: Uri, + val croppedUri: Uri, + val croppedRect: RectF, + val name: String, + val nameRectF: RectF, + val brandName: String, + val brandNameRectF: RectF, + val approveBrandName: String, + val barcode: String, + val barcodeRectF: RectF, + val expiredAt: Date, + val expiredAtRectF: RectF, + val approveExpiredAt: Boolean, + val isCashCard: Boolean, + val balance: String, + val balanceRectF: RectF, + val memo: String, + val isUsed: Boolean, + val createdAt: Date +) { + val originFileName = "origin$id" +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/ConcurrencyTextField.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/ConcurrencyTextField.kt new file mode 100644 index 000000000..795b016f9 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/ConcurrencyTextField.kt @@ -0,0 +1,55 @@ +package com.lighthouse.presentation.ui.common + +import android.content.Context +import android.util.AttributeSet +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.AbstractComposeView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.ui.common.compose.ConcurrencyField + +class ConcurrencyTextField @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0 +) : AbstractComposeView(context, attrs, defStyle) { + + var value by mutableStateOf(0) + var editable by mutableStateOf(true) + var suffixText by mutableStateOf("") + private var listener: ValueListener? = null + + init { + attrs?.let { + context.obtainStyledAttributes(it, R.styleable.ConcurrencyTextField).run { + value = getInt(R.styleable.ConcurrencyTextField_value, 0) + editable = getBoolean(R.styleable.ConcurrencyTextField_editable, true) + suffixText = getString(R.styleable.ConcurrencyTextField_suffixText) + ?: context.getString(R.string.all_cash_origin_unit) + + recycle() + } + } + } + + @Composable + override fun Content() { + MaterialTheme { + ConcurrencyField(value = value, editable = editable, suffixText = suffixText) { + value = it + listener?.onValueChanged(it) + } + } + } + + fun addOnValueListener(listener: ValueListener) { + this.listener = listener + } +} + +fun interface ValueListener { + fun onValueChanged(value: Int) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/FragmentViewBindingDelegate.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/FragmentViewBindingDelegate.kt new file mode 100644 index 000000000..b81c2bf49 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/FragmentViewBindingDelegate.kt @@ -0,0 +1,60 @@ +package com.lighthouse.presentation.ui.common + +import android.view.View +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +class FragmentViewBindingDelegate( + private val fragment: Fragment, + bindingClass: Class +) : ReadOnlyProperty { + + private var binding: VB? = null + + private val bindMethod = bindingClass.getMethod("bind", View::class.java) + + private val fragmentObserver = object : DefaultLifecycleObserver { + private val viewLifecycleOwnerObserver = Observer { viewLifecycleOwner -> + viewLifecycleOwner?.lifecycle?.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + binding = null + } + }) + } + + override fun onCreate(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerObserver) + } + + override fun onDestroy(owner: LifecycleOwner) { + fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerObserver) + fragment.lifecycle.removeObserver(this) + } + } + + override fun getValue(thisRef: Fragment, property: KProperty<*>): VB { + binding?.let { + return it + } + + fragment.lifecycle.addObserver(fragmentObserver) + + val lifecycle = fragment.viewLifecycleOwner.lifecycle + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + throw IllegalStateException("Fragment 의 View 가 제거 됐을 땐 Binding 을 가져오면 안 된다.") + } + + val value = bindMethod.invoke(null, thisRef.requireView()) as VB + return value.also { + binding = it + } + } +} + +inline fun Fragment.viewBindings() = FragmentViewBindingDelegate(this, VB::class.java) diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/GifticonViewHolderType.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/GifticonViewHolderType.kt new file mode 100644 index 000000000..0ae4d44a2 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/GifticonViewHolderType.kt @@ -0,0 +1,6 @@ +package com.lighthouse.presentation.ui.common + +enum class GifticonViewHolderType { + VERTICAL, + HORIZONTAL +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/UiState.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/UiState.kt new file mode 100644 index 000000000..389da2d6c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/UiState.kt @@ -0,0 +1,9 @@ +package com.lighthouse.presentation.ui.common + +sealed class UiState { + object Loading : UiState() + data class Success(val item: T) : UiState() + object NetworkFailure : UiState() + object NotFoundResults : UiState() + object Failure : UiState() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/VerticalTextView.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/VerticalTextView.kt new file mode 100644 index 000000000..26baa65f6 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/VerticalTextView.kt @@ -0,0 +1,58 @@ +package com.lighthouse.presentation.ui.common + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import com.lighthouse.presentation.R + +/** + * TextView 를 세로로 회전한 뷰 + * + * @property topDown true 일 때 시계 방향 회전, false 일 때 반시계 방향 회전 + * + * 코드 참고: [stackoverflow.com/a/45414489](https://stackoverflow.com/a/45414489) + */ +class VerticalTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : androidx.appcompat.widget.AppCompatTextView(context, attrs, defStyleAttr) { + + var topDown: Boolean = DEFAULT_TOP_DOWN + + init { + attrs?.let { + context.obtainStyledAttributes(it, R.styleable.VerticalTextView).run { + topDown = getBoolean(R.styleable.VerticalTextView_topDown, DEFAULT_TOP_DOWN) + recycle() + } + } + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int + ) { + super.onMeasure(heightMeasureSpec, widthMeasureSpec) + setMeasuredDimension(measuredHeight, measuredWidth) + } + + override fun onDraw(canvas: Canvas) { + if (topDown) { + canvas.translate(width.toFloat(), 0f) + canvas.rotate(90f) + } else { + canvas.translate(0f, height.toFloat()) + canvas.rotate(-90f) + } + canvas.translate( + compoundPaddingLeft.toFloat(), + extendedPaddingTop.toFloat() + ) + layout.draw(canvas) + } + + companion object { + private const val DEFAULT_TOP_DOWN = true + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/compose/ConcurrencyTextField.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/compose/ConcurrencyTextField.kt new file mode 100644 index 000000000..a6e88ca9b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/compose/ConcurrencyTextField.kt @@ -0,0 +1,99 @@ +package com.lighthouse.presentation.ui.common.compose + +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.toNumberFormat +import com.lighthouse.presentation.ui.common.compose.ConcurrencyFormatVisualTransformation.Companion.MAX_LENGTH + +@Composable +fun ConcurrencyField( + modifier: Modifier = Modifier, + value: Int = 0, + textStyle: TextStyle? = null, + editable: Boolean = true, + suffixText: String = stringResource(id = R.string.all_cash_origin_unit), + upperLimit: Int = Int.MAX_VALUE, + onValueChanged: (Int) -> Unit = {} +) { + val typography = textStyle ?: MaterialTheme.typography.body1 + var text = value.toString() + + BasicTextField( + value = text, + onValueChange = { + val inputText = it.filter { c -> c.isDigit() }.let { filtered -> + filtered.substring(0, minOf(MAX_LENGTH, filtered.length)) // 붙여넣기 했을 때 최대 10개 까지만 입력 되도록 제한 + } + if (inputText.isBlank()) { + text = "" + return@BasicTextField + } + if (inputText.length >= MAX_LENGTH) { + return@BasicTextField + } + if (inputText.toInt() > upperLimit) { + return@BasicTextField + } + text = inputText + onValueChanged(inputText.toIntOrNull() ?: 0) + }, + modifier = modifier, + readOnly = editable.not(), + singleLine = true, + visualTransformation = ConcurrencyFormatVisualTransformation(suffixText), + textStyle = typography, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + ) +} + +class ConcurrencyFormatVisualTransformation(val suffixText: String = "") : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val numberWithComma = text.text.toNumberFormat() + + return TransformedText( + text = AnnotatedString(numberWithComma + suffixText), + offsetMapping = object : OffsetMapping { + + override fun originalToTransformed(offset: Int): Int { + val rightLength = text.lastIndex - offset + val commasAtRight = rightLength / CHUNK_SIZE + return numberWithComma.lastIndex - rightLength - commasAtRight + } + + override fun transformedToOriginal(offset: Int): Int { + val commas = (text.lastIndex / CHUNK_SIZE).coerceAtLeast(0) + val rightOffset = numberWithComma.length - offset + val commasAtRight = rightOffset / (CHUNK_SIZE + 1) + return if (offset >= (numberWithComma + suffixText).length) { + text.length + } else { + offset - (commas - commasAtRight) + } + } + } + ) + } + + companion object { + const val CHUNK_SIZE = 3 + const val MAX_LENGTH = 10 + } +} + +@Preview +@Composable +fun ConcurrencyFieldPreview() { + ConcurrencyField(value = 0, editable = true) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/compose/TextCheckbox.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/compose/TextCheckbox.kt new file mode 100644 index 000000000..7fc32e8e9 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/compose/TextCheckbox.kt @@ -0,0 +1,41 @@ +package com.lighthouse.presentation.ui.common.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Checkbox +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle + +@Composable +fun TextCheckbox( + checked: Boolean, + text: String, + textStyle: TextStyle = MaterialTheme.typography.body2, + onCheckedChanged: (checked: Boolean) -> Unit = {} +) { + var checkedState by remember { mutableStateOf(checked) } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = checkedState, onCheckedChange = { checked -> + checkedState = checked + onCheckedChanged(checkedState) + }) + Text( + text = text, + style = textStyle, + modifier = Modifier.clickable { + checkedState = checkedState.not() + onCheckedChanged(checkedState) + } + ) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/ConfirmationDialog.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/ConfirmationDialog.kt new file mode 100644 index 000000000..260cdea9c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/ConfirmationDialog.kt @@ -0,0 +1,118 @@ +package com.lighthouse.presentation.ui.common.dialog + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import android.view.View.OnClickListener +import android.view.WindowManager +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.DialogConfirmationBinding +import com.lighthouse.presentation.ui.common.viewBindings + +class ConfirmationDialog : DialogFragment(R.layout.dialog_confirmation) { + + private val binding: DialogConfirmationBinding by viewBindings() + + private var initTitle: String? = null + private var initMessage: String? = null + private var initOkText: String? = null + private var initCancelText: String? = null + + fun setTitle(title: String) { + initTitle = title + } + + fun setMessage(message: String) { + initMessage = message + } + + fun setOkText(okText: String) { + initOkText = okText + } + + fun setCancelText(cancelText: String) { + initCancelText = cancelText + } + + private var onOkClickListener: OnClickListener? = null + fun setOnOkClickListener(listener: (() -> Unit)?) { + onOkClickListener = listener?.let { + OnClickListener { it() } + } + } + + private var onCancelClickListener: OnClickListener? = null + fun setOnCancelListener(listener: (() -> Unit)?) { + onCancelClickListener = listener?.let { + OnClickListener { it() } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.apply { + lifecycleOwner = viewLifecycleOwner + } + + binding.tvTitle.apply { + text = initTitle ?: text + isVisible = text != "" + } + binding.tvMessage.apply { + text = initMessage ?: text + isVisible = text != "" + } + binding.tvOk.apply { + text = initOkText ?: text + isVisible = text != "" + } + binding.tvCancel.apply { + text = initCancelText ?: text + isVisible = text != "" + } + + setUpClickListener() + } + + private fun setUpClickListener() { + binding.root.setOnClickListener { + dismiss() + } + + binding.tvOk.setOnClickListener { v -> + if (onOkClickListener != null) { + onOkClickListener?.onClick(v) + } + dismiss() + } + binding.tvCancel.setOnClickListener { v -> + if (onCancelClickListener != null) { + onCancelClickListener?.onClick(v) + } + dismiss() + } + } + + override fun onResume() { + super.onResume() + + dialog?.window?.apply { + attributes = attributes.apply { + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/OriginImageDialog.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/OriginImageDialog.kt new file mode 100644 index 000000000..2dbc98f60 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/OriginImageDialog.kt @@ -0,0 +1,56 @@ +package com.lighthouse.presentation.ui.common.dialog + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import com.lighthouse.presentation.R +import com.lighthouse.presentation.binding.loadUri +import com.lighthouse.presentation.databinding.DialogOriginImageBinding +import com.lighthouse.presentation.extension.getParcelableCompat +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.viewBindings + +class OriginImageDialog : DialogFragment(R.layout.dialog_origin_image) { + + private val binding: DialogOriginImageBinding by viewBindings() + + private val originUri + get() = arguments?.getParcelableCompat(Extras.KEY_ORIGIN_IMAGE, Uri::class.java) + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.apply { + lifecycleOwner = viewLifecycleOwner + } + + binding.root.setOnClickListener { + dismiss() + } + + binding.ivOrigin.loadUri(originUri) + } + + override fun onResume() { + super.onResume() + + dialog?.window?.apply { + attributes = attributes.apply { + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/ProgressDialog.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/ProgressDialog.kt new file mode 100644 index 000000000..36f3e0421 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/ProgressDialog.kt @@ -0,0 +1,41 @@ +package com.lighthouse.presentation.ui.common.dialog + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.DialogProgressBinding +import com.lighthouse.presentation.ui.common.viewBindings + +class ProgressDialog : DialogFragment(R.layout.dialog_progress) { + + private val binding: DialogProgressBinding by viewBindings() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.lifecycleOwner = viewLifecycleOwner + } + + override fun onResume() { + super.onResume() + + dialog?.window?.apply { + attributes = attributes.apply { + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/OnDatePickListener.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/OnDatePickListener.kt new file mode 100644 index 000000000..5e393742e --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/OnDatePickListener.kt @@ -0,0 +1,5 @@ +package com.lighthouse.presentation.ui.common.dialog.datepicker + +interface OnDatePickListener { + fun onDatePick(year: Int, month: Int, date: Int) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/SpinnerDatePicker.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/SpinnerDatePicker.kt new file mode 100644 index 000000000..aa638914c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/SpinnerDatePicker.kt @@ -0,0 +1,88 @@ +package com.lighthouse.presentation.ui.common.dialog.datepicker + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.DialogSpinnerDatePickerBinding +import com.lighthouse.presentation.ui.common.viewBindings +import java.util.Calendar +import java.util.Date + +class SpinnerDatePicker : DialogFragment(R.layout.dialog_spinner_date_picker) { + + private val binding: DialogSpinnerDatePickerBinding by viewBindings() + + private val viewModel: SpinnerDatePickerViewModel by viewModels() + + private var initDate = Date() + fun setDate(date: Date) { + initDate = date + } + + fun setDate(year: Int, month: Int, dayOfMonth: Int) { + val date = Calendar.getInstance().let { + it.set(year, month, dayOfMonth) + it.time + } + setDate(date) + } + + private var onDatePickListener: OnDatePickListener? = null + fun setOnDatePickListener(listener: ((Int, Int, Int) -> Unit)?) { + onDatePickListener = listener?.let { + object : OnDatePickListener { + override fun onDatePick(year: Int, month: Int, date: Int) { + it(year, month, date) + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + window?.apply { + setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + } + viewModel.setDate(initDate) + binding.root.setOnClickListener { + dismiss() + } + + binding.btnOk.setOnClickListener { + onDatePickListener?.onDatePick(viewModel.year, viewModel.month, viewModel.dayOfMonth) + dismiss() + } + } + + override fun onResume() { + super.onResume() + + dialog?.window?.apply { + attributes = attributes.apply { + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + } + } + + binding.apply { + npYear.value = viewModel.year + npMonth.value = viewModel.month + npDayOfMonth.value = viewModel.dayOfMonth + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/SpinnerDatePickerViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/SpinnerDatePickerViewModel.kt new file mode 100644 index 000000000..33de5be59 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/dialog/datepicker/SpinnerDatePickerViewModel.kt @@ -0,0 +1,76 @@ +package com.lighthouse.presentation.ui.common.dialog.datepicker + +import androidx.lifecycle.ViewModel +import com.lighthouse.presentation.extension.toDayOfMonth +import com.lighthouse.presentation.extension.toMonth +import com.lighthouse.presentation.extension.toYear +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Calendar +import java.util.Date +import java.util.Locale + +class SpinnerDatePickerViewModel : ViewModel() { + + private val today = Calendar.getInstance(Locale.getDefault()) + var year = today.get(Calendar.YEAR) + var month = today.get(Calendar.MONTH) + 1 + var dayOfMonth = today.get(Calendar.DAY_OF_MONTH) + + fun setDate(date: Date) { + year = date.toYear() + month = date.toMonth() + dayOfMonth = date.toDayOfMonth() + _maxDayOfMonth.value = getMaxDayOfMonth(year, month) + } + + private val _minYear = MutableStateFlow(MIN_YEAR) + val minYear = _minYear.asStateFlow() + + private val _maxYear = MutableStateFlow(MAX_YEAR) + val maxYear = _maxYear.asStateFlow() + + private val _minMonth = MutableStateFlow(MIN_MONTH) + val minMonth = _minMonth.asStateFlow() + + private val _maxMonth = MutableStateFlow(MAX_MONTH) + val maxMonth = _maxMonth.asStateFlow() + + private val _minDayOfMonth = MutableStateFlow(MIN_DATE) + val minDayOfMonth = _minDayOfMonth.asStateFlow() + + private val _maxDayOfMonth = MutableStateFlow(MAX_DATE) + val maxDayOfMonth = _maxDayOfMonth.asStateFlow() + + // 윤년 확인 + private fun getMaxDayOfMonth(year: Int, month: Int): Int { + return if (month == 2 && year % 4 == 0 && year % 100 != 0) 29 else dayOfMonthPreset[month - 1] + } + + fun changeYearValue(newValue: Int) { + year = newValue + _maxDayOfMonth.value = getMaxDayOfMonth(year, month) + } + + fun changeMonthValue(newValue: Int) { + month = newValue + _maxDayOfMonth.value = getMaxDayOfMonth(year, month) + } + + fun changeDayOfMonthValue(newValue: Int) { + dayOfMonth = newValue + } + + companion object { + private val dayOfMonthPreset = arrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) + + private const val MIN_YEAR = 1900 + private const val MAX_YEAR = 3000 + + private const val MIN_MONTH = 1 + private const val MAX_MONTH = 12 + + private const val MIN_DATE = 1 + private const val MAX_DATE = 31 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/BalanceTextInputEditText.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/BalanceTextInputEditText.kt new file mode 100644 index 000000000..b7c5e532b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/BalanceTextInputEditText.kt @@ -0,0 +1,71 @@ +package com.lighthouse.presentation.ui.common.view + +import android.content.Context +import android.util.AttributeSet +import com.lighthouse.presentation.extension.toDigit +import java.text.DecimalFormat + +class BalanceTextInputEditText(context: Context, attrs: AttributeSet) : FormattedTextInputEditText(context, attrs) { + + private val balanceFormat = DecimalFormat("###,###,###") + + init { + filters = arrayOf(RealValueLengthFilter(7)) + } + + override fun onTransformedNewValue( + newString: String, + oldDisplayValue: String, + start: Int, + before: Int, + count: Int + ): String { + return if (before == 1 && count == 0 && start < oldDisplayValue.length && oldDisplayValue[start] == ',') { + transformedToValue( + newString.substring(0, Integer.max(start - 1, 0)) + newString.substring( + Integer.max(start, 0), + newString.length + ) + ) + } else { + transformedToValue(newString) + } + } + + override fun calculateSelection( + newDisplayValue: String, + oldDisplayValue: String, + start: Int, + before: Int, + count: Int + ): Int { + return if (oldDisplayValue.length == start + before) { + newDisplayValue.length + } else { + val endStringCount = Integer.max(oldDisplayValue.length - start - before, 0) + val oldDividerCount = + oldDisplayValue.substring(start + before, oldDisplayValue.length).filter { it == ',' }.length + val endNumCount = Integer.max(endStringCount - oldDividerCount, 0) + var index = 0 + var numCount = 0 + while (newDisplayValue.lastIndex - index >= 0 && (numCount < endNumCount || newDisplayValue[newDisplayValue.lastIndex - index] == ',')) { + if (newDisplayValue.lastIndex - index < 0) { + break + } + if (newDisplayValue[newDisplayValue.lastIndex - index] != ',') { + numCount += 1 + } + index += 1 + } + newDisplayValue.lastIndex - index + 1 + } + } + + override fun valueToTransformed(text: String): String { + return balanceFormat.format(text.toDigit()) + } + + override fun transformedToValue(text: String): String { + return text.filter { it.isDigit() }.toDigit().toString() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/BarcodeTextInputEditText.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/BarcodeTextInputEditText.kt new file mode 100644 index 000000000..1edaec32c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/BarcodeTextInputEditText.kt @@ -0,0 +1,70 @@ +package com.lighthouse.presentation.ui.common.view + +import android.content.Context +import android.util.AttributeSet + +class BarcodeTextInputEditText( + context: Context, + attrs: AttributeSet +) : FormattedTextInputEditText(context, attrs) { + + init { + filters = arrayOf(RealValueLengthFilter(24)) + } + + override fun onTransformedNewValue( + newString: String, + oldDisplayValue: String, + start: Int, + before: Int, + count: Int + ): String { + return if (before == 1 && count == 0 && start < oldDisplayValue.length && oldDisplayValue[start] == ' ') { + transformedToValue( + newString.substring(0, Integer.max(start - 1, 0)) + newString.substring( + Integer.max(start, 0), + newString.length + ) + ) + } else { + transformedToValue(newString) + } + } + + override fun calculateSelection( + newDisplayValue: String, + oldDisplayValue: String, + start: Int, + before: Int, + count: Int + ): Int { + return if (oldDisplayValue.length == start + before) { + newDisplayValue.length + } else { + val endStringCount = Integer.max(oldDisplayValue.length - start - before, 0) + val oldDividerCount = + oldDisplayValue.substring(start + before, oldDisplayValue.length).filter { it == ' ' }.length + val endNumCount = Integer.max(endStringCount - oldDividerCount, 0) + var index = 0 + var numCount = 0 + while ( + newDisplayValue.lastIndex - index >= 0 && + (numCount < endNumCount || newDisplayValue[newDisplayValue.lastIndex - index] == ' ') + ) { + if (newDisplayValue[newDisplayValue.lastIndex - index] != ' ') { + numCount += 1 + } + index += 1 + } + newDisplayValue.lastIndex - index + 1 + } + } + + override fun valueToTransformed(text: String): String { + return text.chunked(4).joinToString(" ") + } + + override fun transformedToValue(text: String): String { + return text.filter { it.isDigit() } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/FormattedTextInputEditText.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/FormattedTextInputEditText.kt new file mode 100644 index 000000000..b602cc016 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/FormattedTextInputEditText.kt @@ -0,0 +1,87 @@ +package com.lighthouse.presentation.ui.common.view + +import android.content.Context +import android.text.Editable +import android.text.Selection +import android.text.TextWatcher +import android.util.AttributeSet +import com.google.android.material.textfield.TextInputEditText + +abstract class FormattedTextInputEditText( + context: Context, + attrs: AttributeSet +) : TextInputEditText(context, attrs) { + + protected var realValue = "" + protected var displayValue = "" + + fun setValue(newValue: String) { + if (realValue == newValue) { + return + } + realValue = newValue + displayValue = valueToTransformed(newValue) + setText(displayValue, displayValue.length) + } + + protected fun setText(str: String, selection: Int) { + setText(str) + Selection.setSelection(text, Integer.min(selection, text?.length ?: 0)) + } + + init { + setUpOnTextChanged() + } + + private fun setUpOnTextChanged() { + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + val newString = s.toString() + val oldDisplayValue = displayValue + if (oldDisplayValue == newString) { + return + } + val newValue = onTransformedNewValue(newString, oldDisplayValue, start, before, count) + + val newDisplayValue = valueToTransformed(newValue) + val newSelection = calculateSelection(newDisplayValue, oldDisplayValue, start, before, count) + + realValue = newValue + displayValue = newDisplayValue + setText(newDisplayValue, newSelection) + + onChangeValueListener?.onChangeValue(newValue) + } + + override fun afterTextChanged(s: Editable?) {} + }) + } + + protected abstract fun onTransformedNewValue( + newString: String, + oldDisplayValue: String, + start: Int, + before: Int, + count: Int + ): String + + protected abstract fun calculateSelection( + newDisplayValue: String, + oldDisplayValue: String, + start: Int, + before: Int, + count: Int + ): Int + + protected abstract fun valueToTransformed(text: String): String + + protected abstract fun transformedToValue(text: String): String + + var onChangeValueListener: OnChangeValueListener? = null + + interface OnChangeValueListener { + fun onChangeValue(value: String) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/RealValueLengthFilter.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/RealValueLengthFilter.kt new file mode 100644 index 000000000..cd5e0c9a2 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/common/view/RealValueLengthFilter.kt @@ -0,0 +1,32 @@ +package com.lighthouse.presentation.ui.common.view + +import android.text.InputFilter +import android.text.Spanned +import kotlin.math.max + +class RealValueLengthFilter(private val maxLength: Int) : InputFilter { + override fun filter( + source: CharSequence, + srcStart: Int, + end: Int, + dst: Spanned, + dstStart: Int, + dstEnd: Int + ): CharSequence { + var srcStringCount = 0 + var srcCount = 0 + val dstCount = dst.substring(0, dstStart).count { it.isDigit() } + + dst.substring(dstEnd, dst.length).count { it.isDigit() } + while (maxLength > srcCount + dstCount) { + if (source.length > srcStart + srcStringCount) { + if (source[srcStart + srcStringCount].isDigit()) { + srcCount += 1 + } + srcStringCount += 1 + } else { + break + } + } + return source.substring(srcStart, srcStart + max(srcStringCount, 0)) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/CropGifticonActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/CropGifticonActivity.kt new file mode 100644 index 000000000..9b84f826a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/CropGifticonActivity.kt @@ -0,0 +1,103 @@ +package com.lighthouse.presentation.ui.cropgifticon + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.RectF +import android.os.Bundle +import androidx.activity.addCallback +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivityCropGifticonBinding +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.cropgifticon.event.CropGifticonEvent +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileOutputStream + +@AndroidEntryPoint +class CropGifticonActivity : AppCompatActivity() { + + private lateinit var binding: ActivityCropGifticonBinding + + private val viewModel: CropGifticonViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_crop_gifticon) + binding.apply { + lifecycleOwner = this@CropGifticonActivity + vm = viewModel + } + + setUpOnBackPressed() + collectEvent() + } + + private fun setUpOnBackPressed() { + onBackPressedDispatcher.addCallback { + cancelCropImage() + } + } + + private fun collectEvent() { + repeatOnStarted { + viewModel.eventsFlow.collect { events -> + when (events) { + is CropGifticonEvent.PopupBackStack -> cancelCropImage() + is CropGifticonEvent.RequestCrop -> requestCropImage() + is CropGifticonEvent.CompleteCrop -> completeCropImage(events.croppedBitmap, events.croppedRect) + is CropGifticonEvent.ShowSnackBar -> showSnackBar(events.uiText) + } + } + } + } + + private fun showSnackBar(uiText: UIText) { + Snackbar.make(binding.root, uiText.asString(applicationContext), Snackbar.LENGTH_SHORT).show() + } + + private fun requestCropImage() { + lifecycleScope.launch { + binding.cropImageView.cropImage() + } + } + + private fun cancelCropImage() { + setResult(Activity.RESULT_CANCELED) + finish() + } + + private fun completeCropImage(croppedBitmap: Bitmap, croppedRect: RectF) { + lifecycleScope.launch { + val file = getFileStreamPath(TEMP_FILE_PATH) + file.delete() + + withContext(Dispatchers.IO) { + FileOutputStream(file).use { + croppedBitmap.compress(Bitmap.CompressFormat.JPEG, 100, it) + } + } + + val intent = Intent().apply { + putExtra(Extras.KEY_CROPPED_IMAGE, file.toUri()) + putExtra(Extras.KEY_CROPPED_RECT, croppedRect) + } + setResult(Activity.RESULT_OK, intent) + finish() + } + } + + companion object { + private const val TEMP_FILE_PATH = "cropped.jpg" + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/CropGifticonViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/CropGifticonViewModel.kt new file mode 100644 index 000000000..4fe092e93 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/CropGifticonViewModel.kt @@ -0,0 +1,62 @@ +package com.lighthouse.presentation.ui.cropgifticon + +import android.graphics.Bitmap +import android.graphics.RectF +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.cropgifticon.event.CropGifticonEvent +import com.lighthouse.presentation.ui.cropgifticon.view.CropImageInfo +import com.lighthouse.presentation.util.flow.MutableEventFlow +import com.lighthouse.presentation.util.flow.asEventFlow +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class CropGifticonViewModel @Inject constructor( + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _eventsFlow = MutableEventFlow() + val eventsFlow = _eventsFlow.asEventFlow() + + var cropInfo = CropImageInfo( + savedStateHandle.get(Extras.KEY_ORIGIN_IMAGE), + savedStateHandle.get(Extras.KEY_CROPPED_RECT) + ) + private set + + val enableAspectRatio = savedStateHandle.get(Extras.KEY_ENABLE_ASPECT_RATIO) ?: true + val aspectRatio = savedStateHandle.get(Extras.KEY_ASPECT_RATIO) ?: 1f + + fun cancelCropImage() { + viewModelScope.launch { + _eventsFlow.emit(CropGifticonEvent.PopupBackStack) + } + } + + fun requestCropImage() { + viewModelScope.launch { + _eventsFlow.emit(CropGifticonEvent.RequestCrop) + } + } + + fun onCropImage(croppedBitmap: Bitmap?, croppedRect: RectF) { + viewModelScope.launch { + if (croppedBitmap == null) { + _eventsFlow.emit(CropGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.crop_gifticon_failed))) + } else { + _eventsFlow.emit(CropGifticonEvent.CompleteCrop(croppedBitmap, croppedRect)) + } + } + } + + fun onChangeCropRect(cropRect: RectF) { + cropInfo = cropInfo.copy(croppedRect = cropRect) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/event/CropGifticonEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/event/CropGifticonEvent.kt new file mode 100644 index 000000000..b6be9f430 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/event/CropGifticonEvent.kt @@ -0,0 +1,13 @@ +package com.lighthouse.presentation.ui.cropgifticon.event + +import android.graphics.Bitmap +import android.graphics.RectF +import com.lighthouse.presentation.util.resource.UIText + +sealed class CropGifticonEvent { + + object PopupBackStack : CropGifticonEvent() + object RequestCrop : CropGifticonEvent() + data class CompleteCrop(val croppedBitmap: Bitmap, val croppedRect: RectF) : CropGifticonEvent() + data class ShowSnackBar(val uiText: UIText) : CropGifticonEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/CropImageInfo.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/CropImageInfo.kt new file mode 100644 index 000000000..94477bfc1 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/CropImageInfo.kt @@ -0,0 +1,6 @@ +package com.lighthouse.presentation.ui.cropgifticon.view + +import android.graphics.RectF +import android.net.Uri + +data class CropImageInfo(val uri: Uri?, val croppedRect: RectF?) diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/CropImageView.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/CropImageView.kt new file mode 100644 index 000000000..7788076fd --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/CropImageView.kt @@ -0,0 +1,1058 @@ +package com.lighthouse.presentation.ui.cropgifticon.view + +import android.annotation.SuppressLint +import android.content.ContentResolver.SCHEME_CONTENT +import android.content.ContentResolver.SCHEME_FILE +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.RectF +import android.graphics.Region +import android.os.Build +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.Animation +import android.view.animation.Transformation +import androidx.core.graphics.minus +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.dp +import com.lighthouse.presentation.extension.getBitmap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.max +import kotlin.math.min + +class CropImageView(context: Context, attrs: AttributeSet?) : View(context, attrs) { + + private val coroutineScope = CoroutineScope(Dispatchers.Main) + + private val backgroundPaint by lazy { + Paint().apply { + color = context.getColor(R.color.black_60) + } + } + private val guidelinePaint by lazy { + Paint().apply { + color = context.getColor(R.color.gray_400) + strokeWidth = 1.dp + style = Paint.Style.STROKE + isAntiAlias = true + } + } + private val edgePaint by lazy { + Paint().apply { + color = context.getColor(R.color.white) + strokeWidth = 1.dp + style = Paint.Style.STROKE + isAntiAlias = true + } + } + + private val cornerPaint by lazy { + Paint().apply { + color = context.getColor(R.color.primary) + strokeWidth = CORNER_THICKNESS + isAntiAlias = true + } + } + + private var originBitmap: Bitmap? = null + + // 실제 이미지의 크기에 맞는 Rect + private val realImageRect = RectF() + + // 현재 화면에 그려지고 있는 Rect, Matrix + private val curImageRect = RectF() + private val curCropRect = RectF() + + private val mainMatrix = Matrix() + private val mainInverseMatrix = Matrix() + + private var zoom = 1f + + // AspectRatio = Width / Height + var enableAspectRatio = true + var aspectRatio = 1f + + private var eventType = EventType.NONE + private var touchRange: TouchRange? = null + + private val touchMatrix = Matrix() + private val touchImageRect = RectF() + private val touchCropRect = RectF() + private val touchStartPos = PointF() + private val touchEndPos = PointF() + + // CropRect 가 움직일 수 있는 최대 범위 + private val boundLeft: Float + get() = max(curImageRect.left, 0f) + + private val boundTop: Float + get() = max(curImageRect.top, 0f) + + private val boundRight: Float + get() = min(curImageRect.right, width.toFloat()) + + private val boundBottom: Float + get() = min(curImageRect.bottom, height.toFloat()) + + private var cropInitHorizontalMarginPercent = DEFAULT_MARGIN_PERCENT + private var cropInitVerticalMarginPercent = DEFAULT_MARGIN_PERCENT + + // CropRect 를 줄일 수 있는 최소 범위 + private val calculateMinCropWidth + get() = if (enableAspectRatio) { + if (aspectRatio > 1f) MIN_SIZE * aspectRatio else MIN_SIZE + } else { + MIN_SIZE + } + + private val calculateMinCropHeight + get() = if (enableAspectRatio) { + if (aspectRatio > 1f) MIN_SIZE else MIN_SIZE / aspectRatio + } else { + MIN_SIZE + } + + private val cropZoomAnimation = object : Animation() { + private val startCropRect = RectF() + private val endCropRect = RectF() + + private val startImageRect = RectF() + private val endImageRect = RectF() + + private val startMatrixPoints = FloatArray(9) + private val endMatrixPoints = FloatArray(9) + + private val animCropRect = RectF() + private val animImageRect = RectF() + private val animMatrixPoints = FloatArray(9) + + init { + duration = 300 + fillAfter = true + interpolator = AccelerateDecelerateInterpolator() + } + + override fun applyTransformation(interpolatedTime: Float, t: Transformation?) { + animCropRect.set( + startCropRect.left + (endCropRect.left - startCropRect.left) * interpolatedTime, + startCropRect.top + (endCropRect.top - startCropRect.top) * interpolatedTime, + startCropRect.right + (endCropRect.right - startCropRect.right) * interpolatedTime, + startCropRect.bottom + (endCropRect.bottom - startCropRect.bottom) * interpolatedTime + ) + + animImageRect.set( + startImageRect.left + (endImageRect.left - startImageRect.left) * interpolatedTime, + startImageRect.top + (endImageRect.top - startImageRect.top) * interpolatedTime, + startImageRect.right + (endImageRect.right - startImageRect.right) * interpolatedTime, + startImageRect.bottom + (endImageRect.bottom - startImageRect.bottom) * interpolatedTime + ) + + for (i in animMatrixPoints.indices) { + animMatrixPoints[i] = + startMatrixPoints[i] + (endMatrixPoints[i] - startMatrixPoints[i]) * interpolatedTime + } + + curCropRect.set(animCropRect) + curImageRect.set(animImageRect) + mainMatrix.setValues(animMatrixPoints) + invalidate() + } + + fun setStartState(cropRect: RectF, imageRect: RectF, imageMatrix: Matrix) { + startCropRect.set(cropRect) + startImageRect.set(imageRect) + imageMatrix.getValues(startMatrixPoints) + } + + fun setEndState(cropRect: RectF, imageRectF: RectF, imageMatrix: Matrix) { + endCropRect.set(cropRect) + endImageRect.set(imageRectF) + imageMatrix.getValues(endMatrixPoints) + } + } + + private var onCropImageListener: OnCropImageListener? = null + fun setOnCropImageListener(onCropImageListener: OnCropImageListener?) { + this.onCropImageListener = onCropImageListener + } + + private var onChangeCropRectListener: OnChangeCropRectListener? = null + fun setOnChangeCropRectListener(onChangeCropRectListener: OnChangeCropRectListener?) { + this.onChangeCropRectListener = onChangeCropRectListener + } + + fun setCropInfo(info: CropImageInfo) { + coroutineScope.launch { + originBitmap = withContext(Dispatchers.IO) { + when (info.uri?.scheme) { + SCHEME_CONTENT -> context.contentResolver.getBitmap(info.uri) + SCHEME_FILE -> BitmapFactory.decodeFile(info.uri.path) + else -> null + } + } + initRect(info.croppedRect) + applyMatrix(false) + } + } + + fun cropImage() { + val bitmap = originBitmap + if (bitmap != null) { + val croppedRect = RectF(curCropRect) + mainMatrix.invert(mainInverseMatrix) + mainInverseMatrix.mapRect(croppedRect) + + val croppedBitmap = Bitmap.createBitmap( + bitmap, + croppedRect.left.toInt(), + croppedRect.top.toInt(), + (croppedRect.right - croppedRect.left).toInt(), + (croppedRect.bottom - croppedRect.top).toInt() + ) + onCropImageListener?.onCrop(croppedBitmap, croppedRect) + } else { + onCropImageListener?.onCrop(null, null) + } + } + + // 새로운 이미지 등록시, Rect 초기화 + private fun initRect(croppedRect: RectF? = null) { + mainMatrix.reset() + + val bitmap = originBitmap + if (bitmap != null) { + realImageRect.set(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()) + curImageRect.set(realImageRect) + + if (croppedRect != null && croppedRect != RECT_F_EMPTY) { + curCropRect.set(croppedRect) + applyZoom(bitmap.width, bitmap.height) + } else { + if (enableAspectRatio) { + // AspectRatio 에 맞춰서 CropRect 를 변경 한다 + val aspectWidth = min(realImageRect.width(), realImageRect.height() * aspectRatio) + val aspectHeight = min(realImageRect.width() / aspectRatio, realImageRect.height()) + + val aspectOffsetX = (realImageRect.width() - aspectWidth) / 2 + val aspectOffsetY = (realImageRect.height() - aspectHeight) / 2 + + curCropRect.set( + aspectOffsetX, + aspectOffsetY, + aspectOffsetX + aspectWidth, + aspectOffsetY + aspectHeight + ) + } else { + curCropRect.set(realImageRect) + } + + val widthMargin = curCropRect.width() * cropInitHorizontalMarginPercent + val heightMargin = curCropRect.height() * cropInitVerticalMarginPercent + + curCropRect.inset(widthMargin, heightMargin) + } + } else { + realImageRect.set(RECT_F_EMPTY) + curImageRect.set(RECT_F_EMPTY) + curCropRect.set(RECT_F_EMPTY) + } + } + + private fun applyZoom(width: Int, height: Int) { + if (width == 0 || height == 0 || originBitmap == null) { + return + } + + val cropWidth = curCropRect.width() + val cropHeight = curCropRect.height() + + var newZoom = zoom + /* + * CropWindow 의 가로와 세로가 화면의 50% 이하의 크기가 된다면, + * CropWindow 은 (1 <= a < 2) 의 값을 곱해도 화면 보다 크지 않기 때문에 scale 이 1 이상이 된다. + * 1.5 를 곱해 주는 이유는 한번에 너무 많이 커지는 것이 부담 스럽기 때문이다 + */ + if (zoom < MAX_ZOOM && cropWidth < width * 0.5f && cropHeight < height * 0.5f) { + val scaleW = width / cropWidth * 0.66f * zoom + val scaleH = height / cropHeight * 0.66f * zoom + newZoom = minOf(scaleW, scaleH, MAX_ZOOM) + } + /* + * CropWindow 의 가로와 세로가 화면의 66% 보다 커진다면, + * CropWindow 은 (a > 1.5) 을 곱한다면 scale 이 1 이하가 되게 된다. + * 2 를 곱하 면서 천천히 Zoom을 감소 시킨다 + */ + else if (zoom > MIN_ZOOM && cropWidth > width * 0.66f || cropHeight > height * 0.66f) { + val scaleW = width / cropWidth * 0.5f * zoom + val scaleH = height / cropHeight * 0.5f * zoom + newZoom = max(min(scaleW, scaleH), MIN_ZOOM) + } + + if (newZoom != zoom) { + cropZoomAnimation.setStartState(curCropRect, curImageRect, mainMatrix) + zoom = newZoom + applyMatrix(animate = true) + } + } + + /** + * 화면이 갑자기 많이 바뀌는 상황에서 적절하게 위치를 조정하기 위한 함수 + * ex) onLayout, applyZoom + */ + private fun applyMatrix(animate: Boolean) { + val bitmap = originBitmap + if (width == 0 || height == 0 || bitmap == null) { + return + } + + // 1. 역 행렬을 이용 하여 처음 보였던 이미지를 기준으로 변경한다 + curImageRect.set(realImageRect) + mainMatrix.invert(mainInverseMatrix) + mainInverseMatrix.mapRect(curCropRect) + mainMatrix.reset() + + // 2. 이미지를 화면에 맞게 키운다 + val wScale = width / bitmap.width.toFloat() + val hScale = height / bitmap.height.toFloat() + val scale = min(wScale, hScale) * zoom + mainMatrix.postScale(scale, scale) + mapCurrentImageRectByMatrix() + + // 3. 변경된 이미지를 화면의 가운데로 이동 + val offsetX = (width - curImageRect.width()) / 2 + val offsetY = (height - curImageRect.height()) / 2 + mainMatrix.postTranslate(offsetX, offsetY) + mainMatrix.mapRect(curCropRect) + mapCurrentImageRectByMatrix() + + // 4. ZoomOffset 구하기 + val zoomOffsetX = when { + width > curImageRect.width() -> 0f + else -> max( + min(width / 2 - curCropRect.centerX(), -curImageRect.left), + width - curImageRect.right + ) + } + val zoomOffsetY = when { + height > curImageRect.height() -> 0f + else -> max( + min(height / 2 - curCropRect.centerY(), -curImageRect.top), + height - curImageRect.bottom + ) + } + + mainMatrix.postTranslate(zoomOffsetX, zoomOffsetY) + curCropRect.offset(zoomOffsetX, zoomOffsetY) + mapCurrentImageRectByMatrix() + + if (animate) { + cropZoomAnimation.setEndState(curCropRect, curImageRect, mainMatrix) + startAnimation(cropZoomAnimation) + } else { + invalidate() + } + } + + // 현재 계산된 Matrix 를 이용 하여 curImageRect 를 계산 해준다 + private fun mapCurrentImageRectByMatrix() { + curImageRect.set(realImageRect) + mainMatrix.mapRect(curImageRect) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + applyZoom(right - left, bottom - top) + } + + override fun onDraw(canvas: Canvas) { + val bitmap = originBitmap + if (bitmap != null) { + canvas.drawBitmap(bitmap, mainMatrix, null) + drawShadow(canvas) + if (eventType != EventType.NONE) { + drawGuidelines(canvas) + } + drawEdge(canvas) + drawCorner(canvas) + } + } + + private fun drawShadow(canvas: Canvas) { + canvas.save() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + canvas.clipOutRect(curCropRect) + } else { + canvas.clipRect(curCropRect, Region.Op.DIFFERENCE) + } + canvas.drawRect(curImageRect, backgroundPaint) + canvas.restore() + } + + private fun drawGuidelines(canvas: Canvas) { + val gapWidth = curCropRect.width() / 3 + val gapHeight = curCropRect.height() / 3 + + val x1 = curCropRect.left + gapWidth + val x2 = curCropRect.right - gapWidth + canvas.drawLine(x1, curCropRect.top, x1, curCropRect.bottom, guidelinePaint) + canvas.drawLine(x2, curCropRect.top, x2, curCropRect.bottom, guidelinePaint) + + val y1 = curCropRect.top + gapHeight + val y2 = curCropRect.bottom - gapHeight + canvas.drawLine(curCropRect.left, y1, curCropRect.right, y1, guidelinePaint) + canvas.drawLine(curCropRect.left, y2, curCropRect.right, y2, guidelinePaint) + } + + private fun drawEdge(canvas: Canvas) { + val line = edgePaint.strokeWidth / 2 + canvas.drawRect( + curCropRect.left + line, + curCropRect.top + line, + curCropRect.right - line, + curCropRect.bottom - line, + edgePaint + ) + } + + private fun drawCorner(canvas: Canvas) { + val line = CORNER_THICKNESS / 2 + canvas.drawLine( + curCropRect.left + CORNER_THICKNESS, + curCropRect.top + line, + curCropRect.left + CORNER_LENGTH + CORNER_THICKNESS, + curCropRect.top + line, + cornerPaint + ) + canvas.drawLine( + curCropRect.left + line, + curCropRect.top, + curCropRect.left + line, + curCropRect.top + CORNER_LENGTH + CORNER_THICKNESS, + cornerPaint + ) + + canvas.drawLine( + curCropRect.right - CORNER_THICKNESS, + curCropRect.top + line, + curCropRect.right - CORNER_LENGTH - CORNER_THICKNESS, + curCropRect.top + line, + cornerPaint + ) + canvas.drawLine( + curCropRect.right - line, + curCropRect.top, + curCropRect.right - line, + curCropRect.top + CORNER_LENGTH + CORNER_THICKNESS, + cornerPaint + ) + + canvas.drawLine( + curCropRect.left + CORNER_THICKNESS, + curCropRect.bottom - line, + curCropRect.left + CORNER_LENGTH + CORNER_THICKNESS, + curCropRect.bottom - line, + cornerPaint + ) + canvas.drawLine( + curCropRect.left + line, + curCropRect.bottom, + curCropRect.left + line, + curCropRect.bottom - CORNER_LENGTH - CORNER_THICKNESS, + cornerPaint + ) + + canvas.drawLine( + curCropRect.right - CORNER_THICKNESS, + curCropRect.bottom - line, + curCropRect.right - CORNER_LENGTH - CORNER_THICKNESS, + curCropRect.bottom - line, + cornerPaint + ) + canvas.drawLine( + curCropRect.right - line, + curCropRect.bottom, + curCropRect.right - line, + curCropRect.bottom - CORNER_LENGTH - CORNER_THICKNESS, + cornerPaint + ) + } + + private var activePointerId: Int? = null + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (isActivePointer(event).not()) { + return false + } + + setTouchPos(event) + if (getEventType(event) == EventType.NONE) { + return false + } + + actionEvent() + cleanEvent(event) + return true + } + + private fun isActivePointer(event: MotionEvent): Boolean { + val curPointerId = event.getPointerId(event.actionIndex) + if (activePointerId == null) { + activePointerId = curPointerId + } + return activePointerId == curPointerId + } + + private fun initTouchInfo() { + touchStartPos.set(touchEndPos) + touchCropRect.set(curCropRect) + touchImageRect.set(curImageRect) + touchMatrix.set(mainMatrix) + } + + private fun setTouchPos(event: MotionEvent) { + touchEndPos.set(event.x, event.y) + if (event.action == MotionEvent.ACTION_DOWN) { + initTouchInfo() + } + } + + private fun getEventType(event: MotionEvent): EventType { + if (event.action == MotionEvent.ACTION_DOWN) { + touchRange = getTouchRange(event.x, event.y) + eventType = when (touchRange) { + TouchRange.CENTER -> EventType.MOVE + null -> EventType.NONE + else -> EventType.RESIZE + } + } + return eventType + } + + private fun actionEvent() { + when (eventType) { + EventType.RESIZE -> if (enableAspectRatio && aspectRatio > 0f) { + resizeCropWithFixedAspectRatio() + } else { + resizeCropWithFreeAspectRatio() + } + EventType.MOVE -> moveCrop() + else -> {} + } + } + + private fun cleanEvent(event: MotionEvent) { + when (event.action) { + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_UP -> { + if (eventType == EventType.RESIZE) { + applyZoom(width, height) + } + + eventType = EventType.NONE + activePointerId = null + + val croppedRect = RectF(curCropRect) + mainMatrix.invert(mainInverseMatrix) + mainInverseMatrix.mapRect(croppedRect) + onChangeCropRectListener?.onChange(croppedRect) + } + } + } + + private fun getTouchRange(x: Float, y: Float): TouchRange? { + return when { + containLeft(x, y) -> when { + containTop(x, y) -> TouchRange.LEFT_TOP + containBottom(x, y) -> TouchRange.LEFT_BOTTOM + else -> TouchRange.LEFT + } + containRight(x, y) -> when { + containTop(x, y) -> TouchRange.RIGHT_TOP + containBottom(x, y) -> TouchRange.RIGHT_BOTTOM + else -> TouchRange.RIGHT + } + containTop(x, y) -> TouchRange.TOP + containBottom(x, y) -> TouchRange.BOTTOM + containCenter(x, y) -> TouchRange.CENTER + else -> null + } + } + + private fun containLeft(x: Float, y: Float): Boolean { + val innerRange = + min(max(curCropRect.width().toInt() - EDGE_TOUCH_RANGE * 2, 0), EDGE_TOUCH_RANGE) + return x in curCropRect.left - EDGE_TOUCH_RANGE..curCropRect.left + innerRange && + y in curCropRect.top - EDGE_TOUCH_RANGE..curCropRect.bottom + EDGE_TOUCH_RANGE + } + + private fun containRight(x: Float, y: Float): Boolean { + val innerRange = + min(max((curCropRect.width().toInt() - EDGE_TOUCH_RANGE * 2), 0), EDGE_TOUCH_RANGE) + return x in curCropRect.right - innerRange..curCropRect.right + EDGE_TOUCH_RANGE && + y in curCropRect.top - EDGE_TOUCH_RANGE..curCropRect.bottom + EDGE_TOUCH_RANGE + } + + private fun containTop(x: Float, y: Float): Boolean { + val innerRange = + min(max((curCropRect.height().toInt() - EDGE_TOUCH_RANGE * 2), 0), EDGE_TOUCH_RANGE) + return x in curCropRect.left - EDGE_TOUCH_RANGE..curCropRect.right + EDGE_TOUCH_RANGE && + y in curCropRect.top - EDGE_TOUCH_RANGE..curCropRect.top + innerRange + } + + private fun containBottom(x: Float, y: Float): Boolean { + val innerRange = + min(max((curCropRect.height().toInt() - EDGE_TOUCH_RANGE * 2), 0), EDGE_TOUCH_RANGE) + return x in curCropRect.left - EDGE_TOUCH_RANGE..curCropRect.right + EDGE_TOUCH_RANGE && + y in curCropRect.bottom - innerRange..curCropRect.bottom + EDGE_TOUCH_RANGE + } + + private fun containCenter(x: Float, y: Float) = curCropRect.contains(x, y) + + private fun moveCrop() { + val diff = touchEndPos.minus(touchStartPos) + + val screenStart = max(curImageRect.left, 0f) + val screenEnd = min(curImageRect.right, width.toFloat()) + val screenTop = max(curImageRect.top, 0f) + val screenBottom = min(curImageRect.bottom, height.toFloat()) + + val cropOffsetX = when { + touchCropRect.left + diff.x < screenStart -> screenStart - touchCropRect.left + touchCropRect.right + diff.x > screenEnd -> screenEnd - touchCropRect.right + else -> diff.x + } + val cropOffsetY = when { + touchCropRect.top + diff.y < screenTop -> screenTop - touchCropRect.top + touchCropRect.bottom + diff.y > screenBottom -> screenBottom - touchCropRect.bottom + else -> diff.y + } + + curCropRect.apply { + set(touchCropRect) + offset(cropOffsetX, cropOffsetY) + } + + val overOffsetX = (cropOffsetX - diff.x) * zoom + val overOffsetY = (cropOffsetY - diff.y) * zoom + + val imageOffsetX = when { + touchImageRect.left + overOffsetX > screenStart -> screenStart - touchImageRect.left + touchImageRect.right + overOffsetX < screenEnd -> screenEnd - touchImageRect.right + else -> overOffsetX + } + + val imageOffsetY = when { + touchImageRect.top + overOffsetY > screenTop -> screenTop - touchImageRect.top + touchImageRect.bottom + overOffsetY < screenBottom -> screenBottom - touchImageRect.bottom + else -> overOffsetY + } + + if (imageOffsetX != 0f || imageOffsetY != 0f) { + mainMatrix.set(touchMatrix) + mainMatrix.postTranslate(imageOffsetX, imageOffsetY) + mapCurrentImageRectByMatrix() + initTouchInfo() + } + invalidate() + } + + private fun resizeCropWithFreeAspectRatio() { + val range = touchRange ?: return + val diff = touchEndPos.minus(touchStartPos) + + when (range) { + TouchRange.LEFT, + TouchRange.LEFT_TOP, + TouchRange.LEFT_BOTTOM -> resizeLeft(diff.x) + TouchRange.RIGHT, + TouchRange.RIGHT_TOP, + TouchRange.RIGHT_BOTTOM -> resizeRight(diff.x) + else -> {} + } + + when (range) { + TouchRange.TOP, + TouchRange.LEFT_TOP, + TouchRange.RIGHT_TOP -> resizeTop(diff.y) + TouchRange.BOTTOM, + TouchRange.LEFT_BOTTOM, + TouchRange.RIGHT_BOTTOM -> resizeBottom(diff.y) + else -> {} + } + invalidate() + } + + private fun resizeLeft(diffX: Float, dir: ResizeAddDir = ResizeAddDir.NONE) { + var resizedLeft = touchCropRect.left + diffX + val boundLeft = boundLeft + val boundTop = boundTop + val boundBottom = boundBottom + val minCropWidth = calculateMinCropWidth + val minCropHeight = calculateMinCropHeight + + if (resizedLeft < boundLeft) { + resizedLeft = boundLeft + } + if (resizedLeft > curCropRect.right - minCropWidth) { + resizedLeft = curCropRect.right - minCropWidth + } + + if (dir != ResizeAddDir.NONE) { + var newHeight = (curCropRect.right - resizedLeft) / aspectRatio + if (newHeight < minCropHeight) { + resizedLeft = max(boundLeft, curCropRect.right - minCropHeight * aspectRatio) + newHeight = (curCropRect.right - resizedLeft) / aspectRatio + } + + when (dir) { + ResizeAddDir.TOP -> { + if (newHeight > curCropRect.bottom - boundTop) { + resizedLeft = max(boundLeft, curCropRect.right - (curCropRect.bottom - boundTop) * aspectRatio) + } + } + ResizeAddDir.BOTTOM -> { + if (newHeight > boundBottom - curCropRect.top) { + resizedLeft = + maxOf( + resizedLeft, + boundLeft, + curCropRect.right - (boundBottom - curCropRect.top) * aspectRatio + ) + } + } + ResizeAddDir.VERTICAL -> { + resizedLeft = + maxOf(resizedLeft, boundLeft, curCropRect.right - (boundBottom - boundTop) * aspectRatio) + } + else -> {} + } + } + curCropRect.left = resizedLeft + } + + private fun resizeRight(diffX: Float, dir: ResizeAddDir = ResizeAddDir.NONE) { + var resizedRight = touchCropRect.right + diffX + val boundRight = boundRight + val boundTop = boundTop + val boundBottom = boundBottom + val minCropWidth = calculateMinCropWidth + val minCropHeight = calculateMinCropHeight + + if (resizedRight > boundRight) { + resizedRight = boundRight + } + if (resizedRight < curCropRect.left + minCropWidth) { + resizedRight = curCropRect.left + minCropWidth + } + if (dir != ResizeAddDir.NONE) { + var newHeight = (resizedRight - curCropRect.left) / aspectRatio + if (newHeight < minCropHeight) { + resizedRight = min(boundRight, curCropRect.left + minCropHeight * aspectRatio) + newHeight = (resizedRight - curCropRect.left) / aspectRatio + } + + when (dir) { + ResizeAddDir.TOP -> { + if (newHeight > curCropRect.bottom - boundTop) { + resizedRight = min(boundRight, curCropRect.left + (curCropRect.bottom - boundTop) * aspectRatio) + } + } + ResizeAddDir.BOTTOM -> { + if (newHeight > boundBottom - curCropRect.top) { + resizedRight = + minOf( + resizedRight, + boundRight, + curCropRect.left + (boundBottom - curCropRect.top) * aspectRatio + ) + } + } + ResizeAddDir.VERTICAL -> { + resizedRight = + minOf(resizedRight, boundRight, curCropRect.left + (boundBottom - boundTop) * aspectRatio) + } + else -> {} + } + } + + curCropRect.right = resizedRight + } + + private fun resizeTop(diffY: Float, dir: ResizeAddDir = ResizeAddDir.NONE) { + var resizedTop = touchCropRect.top + diffY + val boundLeft = boundLeft + val boundTop = boundTop + val boundRight = boundRight + val minCropWidth = calculateMinCropWidth + val minCropHeight = calculateMinCropHeight + + if (resizedTop < boundTop) { + resizedTop = boundTop + } + if (resizedTop > curCropRect.bottom - minCropHeight) { + resizedTop = curCropRect.bottom - minCropHeight + } + if (dir != ResizeAddDir.NONE) { + var newWidth = (curCropRect.bottom - resizedTop) * aspectRatio + if (newWidth < minCropWidth) { + resizedTop = max(boundTop, curCropRect.bottom - minCropWidth / aspectRatio) + newWidth = (curCropRect.bottom - resizedTop) * aspectRatio + } + + when (dir) { + ResizeAddDir.LEFT -> { + if (newWidth > curCropRect.right - boundLeft) { + resizedTop = max(boundTop, curCropRect.bottom - (curCropRect.right - boundLeft) / aspectRatio) + } + } + ResizeAddDir.RIGHT -> { + if (newWidth > boundRight - curCropRect.left) { + resizedTop = + maxOf( + resizedTop, + boundTop, + curCropRect.bottom - (boundRight - curCropRect.left) / aspectRatio + ) + } + } + ResizeAddDir.HORIZONTAL -> { + resizedTop = + maxOf(resizedTop, boundTop, curCropRect.bottom - (boundRight - boundLeft) / aspectRatio) + } + else -> {} + } + } + curCropRect.top = resizedTop + } + + private fun resizeBottom(diffY: Float, dir: ResizeAddDir = ResizeAddDir.NONE) { + var resizedBottom = touchCropRect.bottom + diffY + val boundLeft = boundLeft + val boundBottom = boundBottom + val boundRight = boundRight + val minCropWidth = calculateMinCropWidth + val minCropHeight = calculateMinCropHeight + + if (resizedBottom > boundBottom) { + resizedBottom = boundBottom + } + if (resizedBottom < curCropRect.top + minCropHeight) { + resizedBottom = curCropRect.top + minCropHeight + } + if (dir != ResizeAddDir.NONE) { + var newWidth = (resizedBottom - curCropRect.top) * aspectRatio + if (newWidth < minCropWidth) { + resizedBottom = min(boundBottom, curCropRect.top + minCropWidth / aspectRatio) + newWidth = (resizedBottom - curCropRect.top) * aspectRatio + } + + when (dir) { + ResizeAddDir.LEFT -> { + if (newWidth > curCropRect.right - boundLeft) { + resizedBottom = + min(boundBottom, curCropRect.top + (curCropRect.right - boundLeft) / aspectRatio) + } + } + ResizeAddDir.RIGHT -> { + if (newWidth > boundRight - curCropRect.left) { + resizedBottom = + minOf( + resizedBottom, + boundBottom, + curCropRect.top + (boundRight - curCropRect.left) / aspectRatio + ) + } + } + ResizeAddDir.HORIZONTAL -> { + resizedBottom = + minOf(resizedBottom, boundBottom, curCropRect.top + (boundRight - boundLeft) / aspectRatio) + } + else -> {} + } + } + curCropRect.bottom = resizedBottom + } + + private fun resizeCropWithFixedAspectRatio() { + val range = touchRange ?: return + val diff = touchEndPos.minus(touchStartPos) + when (range) { + TouchRange.LEFT_TOP -> resizeLeftTopWithAspectRatio(diff) + TouchRange.TOP -> resizeTopWithAspectRatio(diff.y) + TouchRange.RIGHT_TOP -> resizeRightTopWithAspectRatio(diff) + TouchRange.RIGHT -> resizeRightWithAspectRatio(diff.x) + TouchRange.RIGHT_BOTTOM -> resizeRightBottomWithAspectRatio(diff) + TouchRange.BOTTOM -> resizeBottomWithAspectRatio(diff.y) + TouchRange.LEFT_BOTTOM -> resizeLeftBottomWithAspectRatio(diff) + TouchRange.LEFT -> resizeLeftWithAspectRatio(diff.x) + else -> {} + } + invalidate() + } + + private fun calculateAspectRatio(left: Float, top: Float, right: Float, bottom: Float): Float { + return (right - left) / (bottom - top) + } + + private fun resizeLeftByAspectRatio() { + curCropRect.left = curCropRect.right - curCropRect.height() * aspectRatio + } + + private fun resizeTopByAspectRatio() { + curCropRect.top = curCropRect.bottom - curCropRect.width() / aspectRatio + } + + private fun resizeRightByAspectRatio() { + curCropRect.right = curCropRect.left + curCropRect.height() * aspectRatio + } + + private fun resizeBottomByAspectRatio() { + curCropRect.bottom = curCropRect.top + curCropRect.width() / aspectRatio + } + + private fun resizeHorizontalByAspectRatio() { + curCropRect.inset((curCropRect.width() - curCropRect.height() * aspectRatio) / 2, 0f) + val boundLeft = boundLeft + val boundRight = boundRight + if (curCropRect.left < boundLeft) { + curCropRect.offset(boundLeft - curCropRect.left, 0f) + } + if (curCropRect.right > boundRight) { + curCropRect.offset(boundRight - curCropRect.right, 0f) + } + } + + private fun resizeVerticalByAspectRatio() { + curCropRect.inset(0f, (curCropRect.height() - curCropRect.width() / aspectRatio) / 2) + val boundTop = boundTop + val boundBottom = boundBottom + if (curCropRect.top < boundTop) { + curCropRect.offset(0f, boundTop - curCropRect.top) + } + if (curCropRect.bottom > boundBottom) { + curCropRect.offset(0f, boundBottom - curCropRect.bottom) + } + } + + private fun resizeLeftTopWithAspectRatio(diff: PointF) { + if (calculateAspectRatio( + min(curCropRect.left + diff.x, curCropRect.right - calculateMinCropWidth), + min(curCropRect.top + diff.y, curCropRect.bottom - calculateMinCropHeight), + curCropRect.right, + curCropRect.bottom + ) < aspectRatio + ) { + resizeTop(diff.y, ResizeAddDir.LEFT) + resizeLeftByAspectRatio() + } else { + resizeLeft(diff.x, ResizeAddDir.TOP) + resizeTopByAspectRatio() + } + } + + private fun resizeTopWithAspectRatio(diffY: Float) { + resizeTop(diffY, ResizeAddDir.HORIZONTAL) + resizeHorizontalByAspectRatio() + } + + private fun resizeRightTopWithAspectRatio(diff: PointF) { + if (calculateAspectRatio( + curCropRect.left, + min(curCropRect.top + diff.y, curCropRect.bottom - calculateMinCropHeight), + max(curCropRect.right + diff.x, curCropRect.left + calculateMinCropWidth), + curCropRect.bottom + ) < aspectRatio + ) { + resizeTop(diff.y, ResizeAddDir.RIGHT) + resizeRightByAspectRatio() + } else { + resizeRight(diff.x, ResizeAddDir.TOP) + resizeTopByAspectRatio() + } + } + + private fun resizeRightWithAspectRatio(diffX: Float) { + resizeRight(diffX, ResizeAddDir.VERTICAL) + resizeVerticalByAspectRatio() + } + + private fun resizeRightBottomWithAspectRatio(diff: PointF) { + if (calculateAspectRatio( + curCropRect.left, + curCropRect.top, + max(curCropRect.right + diff.x, curCropRect.left + calculateMinCropWidth), + max(curCropRect.bottom + diff.y, curCropRect.top + calculateMinCropHeight) + ) < aspectRatio + ) { + resizeBottom(diff.y, ResizeAddDir.RIGHT) + resizeRightByAspectRatio() + } else { + resizeRight(diff.x, ResizeAddDir.BOTTOM) + resizeBottomByAspectRatio() + } + } + + private fun resizeBottomWithAspectRatio(diffY: Float) { + resizeBottom(diffY, ResizeAddDir.HORIZONTAL) + resizeHorizontalByAspectRatio() + } + + private fun resizeLeftBottomWithAspectRatio(diff: PointF) { + if (calculateAspectRatio( + min(curCropRect.left + diff.x, curCropRect.right - calculateMinCropWidth), + curCropRect.top, + curCropRect.right, + max(curCropRect.bottom + diff.y, curCropRect.top + calculateMinCropHeight) + ) < aspectRatio + ) { + resizeBottom(diff.y, ResizeAddDir.LEFT) + resizeLeftByAspectRatio() + } else { + resizeLeft(diff.x, ResizeAddDir.BOTTOM) + resizeBottomByAspectRatio() + } + } + + private fun resizeLeftWithAspectRatio(diffX: Float) { + resizeLeft(diffX, ResizeAddDir.VERTICAL) + resizeVerticalByAspectRatio() + } + + enum class TouchRange { + LEFT, LEFT_TOP, TOP, RIGHT_TOP, RIGHT, RIGHT_BOTTOM, BOTTOM, LEFT_BOTTOM, CENTER + } + + enum class EventType { + NONE, MOVE, RESIZE + } + + enum class ResizeAddDir { + LEFT, TOP, RIGHT, BOTTOM, VERTICAL, HORIZONTAL, NONE + } + + companion object { + private val RECT_F_EMPTY = RectF() + + private const val MIN_ZOOM = 1f + private const val MAX_ZOOM = 4f + + private const val DEFAULT_MARGIN_PERCENT = 0.1f + + private val CORNER_THICKNESS = 3.dp + private val CORNER_LENGTH = 12.dp.toInt() + private val MIN_SIZE = (CORNER_LENGTH + CORNER_THICKNESS) * 2 + private val EDGE_TOUCH_RANGE = 24.dp.toInt() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/OnChangeCropRectListener.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/OnChangeCropRectListener.kt new file mode 100644 index 000000000..a43e6414a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/OnChangeCropRectListener.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.ui.cropgifticon.view + +import android.graphics.RectF + +interface OnChangeCropRectListener { + fun onChange(cropRect: RectF) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/OnCropImageListener.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/OnCropImageListener.kt new file mode 100644 index 000000000..864a2b54d --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/cropgifticon/view/OnCropImageListener.kt @@ -0,0 +1,8 @@ +package com.lighthouse.presentation.ui.cropgifticon.view + +import android.graphics.Bitmap +import android.graphics.RectF + +interface OnCropImageListener { + fun onCrop(croppedBitmap: Bitmap?, croppedRect: RectF?) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/CashCardGifticonInfoFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/CashCardGifticonInfoFragment.kt new file mode 100644 index 000000000..1a9748f46 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/CashCardGifticonInfoFragment.kt @@ -0,0 +1,26 @@ +package com.lighthouse.presentation.ui.detailgifticon + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentCashCardGifticonInfoBinding +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.util.Geography + +class CashCardGifticonInfoFragment : Fragment(R.layout.fragment_cash_card_gifticon_info) { + val binding: FragmentCashCardGifticonInfoBinding by viewBindings() + private val viewModel: GifticonDetailViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.vm = viewModel + binding.geo = Geography(requireContext()) + binding.lifecycleOwner = viewLifecycleOwner + binding.ctfBalance.addOnValueListener { + viewModel.editBalance(it) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailActivity.kt new file mode 100644 index 000000000..9ec1e74b5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailActivity.kt @@ -0,0 +1,270 @@ +package com.lighthouse.presentation.ui.detailgifticon + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.core.view.isVisible +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivityGifticonDetailBinding +import com.lighthouse.presentation.databinding.DialogUsageHistoryBinding +import com.lighthouse.presentation.extension.isOnScreen +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extension.scrollToBottom +import com.lighthouse.presentation.extension.show +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.dialog.OriginImageDialog +import com.lighthouse.presentation.ui.common.dialog.datepicker.SpinnerDatePicker +import com.lighthouse.presentation.ui.detailgifticon.dialog.LargeBarcodeDialog +import com.lighthouse.presentation.ui.detailgifticon.dialog.UsageHistoryAdapter +import com.lighthouse.presentation.ui.detailgifticon.dialog.UseGifticonDialog +import com.lighthouse.presentation.ui.edit.modifygifticon.ModifyGifticonActivity +import com.lighthouse.presentation.ui.security.AuthCallback +import com.lighthouse.presentation.ui.security.AuthManager +import com.lighthouse.presentation.util.permission.LocationPermissionManager +import com.lighthouse.presentation.util.permission.core.permissions +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class GifticonDetailActivity : AppCompatActivity() { + + private lateinit var binding: ActivityGifticonDetailBinding + private val viewModel: GifticonDetailViewModel by viewModels() + + private val standardGifticonInfo by lazy { + supportFragmentManager.findFragmentByTag(StandardGifticonInfoFragment::class.java.name) + ?: StandardGifticonInfoFragment() + } + private val cashCardGifticonInfo by lazy { + supportFragmentManager.findFragmentByTag(CashCardGifticonInfoFragment::class.java.name) + ?: CashCardGifticonInfoFragment() + } + + private lateinit var usageHistoryDialog: AlertDialog + private lateinit var useGifticonDialog: UseGifticonDialog + private val usageHistoryAdapter by lazy { UsageHistoryAdapter() } + + private var largeBarcodeDialog: LargeBarcodeDialog? = null + + private val btnMaster by lazy { binding.btnMaster } + private val chip by lazy { binding.chipScrollDownForUseButton } + private val spinnerDatePicker = SpinnerDatePicker() + + private val locationPermission: LocationPermissionManager by permissions() + + @Inject + lateinit var authManager: AuthManager + private val biometricLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> authenticate() + else -> authCallback.onAuthError() + } + } + + private val authCallback = object : AuthCallback { + override fun onAuthSuccess() { + showUseGifticonDialog() + } + + override fun onAuthCancel() { + } + + override fun onAuthError(@StringRes stringId: Int?) { + if (stringId != null) { + Toast.makeText(this@GifticonDetailActivity, getString(stringId), Toast.LENGTH_SHORT).show() + } else { + authenticate() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = DataBindingUtil.setContentView(this, R.layout.activity_gifticon_detail) + binding.lifecycleOwner = this + binding.vm = viewModel + + setSupportActionBar(binding.tbGifticonDetail) + + binding.btnMaster.viewTreeObserver.addOnDrawListener { + chip.post { + chip.isVisible = btnMaster.isOnScreen().not() + } + } + repeatOnStarted { + locationPermission.permissionFlow.collectLatest { + viewModel.updateLocationPermission(it) + } + } + repeatOnStarted { + viewModel.event.collect { event -> + handleEvent(event) + } + } + repeatOnStarted { + viewModel.gifticon.collect { gifticon -> + val fragment = when (gifticon?.isCashCard) { + true -> cashCardGifticonInfo + false -> standardGifticonInfo + else -> null + } + if (fragment != null && fragment.isAdded.not()) { + supportFragmentManager.commit { + replace(binding.fcvGifticonInfo.id, fragment, fragment::class.java.name) + } + } + } + } + repeatOnStarted { + viewModel.failure.collect { + showInvalidDialog() + } + } + } + + private fun handleEvent(event: GifticonDetailEvent) { + when (event) { + is GifticonDetailEvent.ScrollDownForUseButtonClicked -> { + binding.abGifticonDetail.setExpanded(false, true) + binding.svGifticonDetail.scrollToBottom() + } + is GifticonDetailEvent.EditButtonClicked -> { + gotoModifyGifticon(viewModel.gifticon.value?.id) + } + is GifticonDetailEvent.ExistEmptyInfo -> { + Toast.makeText( + this, + getString(R.string.gifticon_detail_exist_empty_info_toast), + Toast.LENGTH_SHORT + ).show() + } + is GifticonDetailEvent.ExpireDateClicked -> { + showDatePickerDialog() + } + is GifticonDetailEvent.UseGifticonButtonClicked -> { + authenticate() + } + is GifticonDetailEvent.ShowAllUsedInfoButtonClicked -> { + showUsageHistoryDialog() + } + is GifticonDetailEvent.UseGifticonComplete -> { + if (::useGifticonDialog.isInitialized && useGifticonDialog.isAdded) { + useGifticonDialog.dismiss() + } + } + is GifticonDetailEvent.ShowOriginalImage -> { + showOriginGifticonDialog(event.origin) + } + is GifticonDetailEvent.ShowLargeBarcode -> { + showLargeBarcodeDialog(event.barcode) + } + else -> { // TODO(이벤트 처리) + } + } + } + + private fun gotoModifyGifticon(gifticonId: String?) { + gifticonId ?: return + val intent = Intent(this, ModifyGifticonActivity::class.java).apply { + putExtra(Extras.KEY_MODIFY_GIFTICON_ID, gifticonId) + } + startActivity(intent) + } + + private fun showDatePickerDialog() { + spinnerDatePicker.show(supportFragmentManager) + } + + private fun showUseGifticonDialog() { + useGifticonDialog = UseGifticonDialog().also { dialog -> + dialog.show(supportFragmentManager, UseGifticonDialog.TAG) + } + } + + private fun showUsageHistoryDialog() { + if (::usageHistoryDialog.isInitialized.not()) { + val usageHistoryView = DataBindingUtil.inflate( + LayoutInflater.from(this), + R.layout.dialog_usage_history, + null, + false + ) + usageHistoryView.vm = viewModel + usageHistoryView.rvUsageHistory.adapter = usageHistoryAdapter + usageHistoryDialog = AlertDialog.Builder(this).setView(usageHistoryView.root).create() + } + + usageHistoryDialog.show() + } + + private fun showInvalidDialog() { + var lastSecond = INVALID_DIALOG_DEADLINE_SECOND // 자동으로 닫힐 시간(초) + val dialog = AlertDialog.Builder(this) + .setTitle(getString(R.string.gifticon_detail_invalid_dialog_title)) + .setNegativeButton(getString(R.string.all_close_button)) { dialog, _ -> + dialog.dismiss() + } + .setOnDismissListener { + finish() + } + .create() + lifecycleScope.launch { + do { + dialog + .setMessage(getString(R.string.gifticon_detail_invalid_dialog_message, lastSecond)) + dialog.show() + delay(1000) + } while (--lastSecond > 0) + + dialog.dismiss() + cancel() + } + } + + private fun authenticate() { + authManager.auth(this, biometricLauncher, authCallback) + } + + private fun showOriginGifticonDialog(path: String) { + val uri = this.getFileStreamPath(path).toUri() + OriginImageDialog().apply { + arguments = Bundle().apply { + putParcelable(Extras.KEY_ORIGIN_IMAGE, uri) + } + }.show(supportFragmentManager) + } + + private fun showLargeBarcodeDialog(barcode: String) { + if (largeBarcodeDialog?.isAdded == true) { + largeBarcodeDialog?.dismiss() + } + largeBarcodeDialog = LargeBarcodeDialog().apply { + arguments = Bundle().apply { + putString(Extras.KEY_BARCODE, barcode) + } + } + largeBarcodeDialog?.show(supportFragmentManager) + } + + companion object { + const val INVALID_DIALOG_DEADLINE_SECOND = 5 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailEvent.kt new file mode 100644 index 000000000..fdc72f9ec --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailEvent.kt @@ -0,0 +1,14 @@ +package com.lighthouse.presentation.ui.detailgifticon + +sealed class GifticonDetailEvent { + object ScrollDownForUseButtonClicked : GifticonDetailEvent() + object ShareButtonClicked : GifticonDetailEvent() + object ShowAllUsedInfoButtonClicked : GifticonDetailEvent() + data class ShowOriginalImage(val origin: String) : GifticonDetailEvent() + data class ShowLargeBarcode(val barcode: String) : GifticonDetailEvent() + object EditButtonClicked : GifticonDetailEvent() + object ExistEmptyInfo : GifticonDetailEvent() + object ExpireDateClicked : GifticonDetailEvent() + object UseGifticonButtonClicked : GifticonDetailEvent() + object UseGifticonComplete : GifticonDetailEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailMode.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailMode.kt new file mode 100644 index 000000000..f3ef161c6 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailMode.kt @@ -0,0 +1,5 @@ +package com.lighthouse.presentation.ui.detailgifticon + +enum class GifticonDetailMode { + UNUSED, USED, EDIT +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailViewModel.kt new file mode 100644 index 000000000..e7f77c013 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/GifticonDetailViewModel.kt @@ -0,0 +1,224 @@ +package com.lighthouse.presentation.ui.detailgifticon + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.usecase.GetGifticonUseCase +import com.lighthouse.domain.usecase.GetUsageHistoriesUseCase +import com.lighthouse.domain.usecase.UnUseGifticonUseCase +import com.lighthouse.domain.usecase.UseCashCardGifticonUseCase +import com.lighthouse.domain.usecase.UseGifticonUseCase +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.toConcurrency +import com.lighthouse.presentation.extension.toDayOfMonth +import com.lighthouse.presentation.extension.toMonth +import com.lighthouse.presentation.extension.toYear +import com.lighthouse.presentation.extra.Extras.KEY_GIFTICON_ID +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.model.CashAmountPreset +import com.lighthouse.presentation.model.GifticonUIModel +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GifticonDetailViewModel @Inject constructor( + stateHandle: SavedStateHandle, + getGifticonUseCase: GetGifticonUseCase, + getUsageHistoryUseCase: GetUsageHistoriesUseCase, + private val useGifticonUseCase: UseGifticonUseCase, + private val useCashCardGifticonUseCase: UseCashCardGifticonUseCase, + private val unUseGifticonUseCase: UnUseGifticonUseCase +) : ViewModel() { + + private val gifticonId = stateHandle.get(KEY_GIFTICON_ID) ?: error("Gifticon id is null") + private val gifticonDbResult = + getGifticonUseCase(gifticonId).stateIn(viewModelScope, SharingStarted.Eagerly, DbResult.Loading) + + private val _mode = MutableStateFlow(GifticonDetailMode.UNUSED) + val mode = _mode.asStateFlow() + + val gifticon: StateFlow = gifticonDbResult.transform { + if (it is DbResult.Success) { + emit(it.data.toPresentation()) + switchMode(if (it.data.isUsed) GifticonDetailMode.USED else GifticonDetailMode.UNUSED) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val usageHistoryDbResult = getUsageHistoryUseCase(gifticonId) + + val usageHistory = usageHistoryDbResult.transform { + if (it is DbResult.Success) { + emit(it.data) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val latestUsageHistory = usageHistory.transform { + emit(it?.last()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val latestUsageHistoryUIText = latestUsageHistory.transform { + if (it == null) return@transform + val date = it.date + emit( + UIText.StringResource( + R.string.gifticon_detail_used_image_label, + date.toYear(), + date.toMonth(), + date.toDayOfMonth() + ) + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, UIText.Empty) + + val balanceUIText: StateFlow = gifticon.transform { + if (it == null) return@transform + emit(UIText.StringResource(R.string.all_balance_label, it.balance.toConcurrency())) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UIText.Empty) + + val failure = gifticonDbResult.transform { + if (it is DbResult.Failure) { + emit(it.throwable) + } + } + + val amountToBeUsed = MutableStateFlow(0) + + private val _event = MutableSharedFlow() + val event = _event.asSharedFlow() + + private val _tempGifticon = MutableStateFlow(null) + val tempGifticon = _tempGifticon.asStateFlow() + + private val _scrollDownChipLabel = MutableStateFlow(UIText.Empty) + val scrollDownChipLabel = _scrollDownChipLabel.asStateFlow() + + private val _masterButtonLabel = MutableStateFlow(UIText.Empty) + val masterButtonLabel = _masterButtonLabel.asStateFlow() + + var hasLocationPermission = MutableStateFlow(false) + private set + + fun scrollDownForUseButtonClicked() { + event(GifticonDetailEvent.ScrollDownForUseButtonClicked) + } + + fun shareButtonClicked() { + event(GifticonDetailEvent.ShareButtonClicked) + } + + fun editButtonClicked() { + event(GifticonDetailEvent.EditButtonClicked) + } + + fun expireDateClicked() { + event(GifticonDetailEvent.ExpireDateClicked) + } + + fun showAllUsedInfoButtonClicked() { + event(GifticonDetailEvent.ShowAllUsedInfoButtonClicked) + } + + fun useGifticonButtonClicked() { + when (mode.value) { + GifticonDetailMode.UNUSED -> { + event(GifticonDetailEvent.UseGifticonButtonClicked) + } + GifticonDetailMode.USED -> { + viewModelScope.launch { + unUseGifticonUseCase(gifticonId) + } + } + GifticonDetailMode.EDIT -> { + if (checkEditValidation().not()) { + event(GifticonDetailEvent.ExistEmptyInfo) + } else { + _mode.value = GifticonDetailMode.UNUSED + } + } + } + } + + fun completeUseGifticonButtonClicked() { + viewModelScope.launch { + if (gifticon.value?.isCashCard == true) { + assert((gifticon.value?.balance ?: 0) >= amountToBeUsed.value) + useCashCardGifticonUseCase(gifticonId, amountToBeUsed.value, hasLocationPermission.value) + amountToBeUsed.value = 0 + event(GifticonDetailEvent.UseGifticonComplete) + } else { + useGifticonUseCase(gifticonId, hasLocationPermission.value) + event(GifticonDetailEvent.UseGifticonComplete) + } + } + } + + fun amountChipClicked(amountPreset: CashAmountPreset) { + amountPreset.amount?.let { amount -> + amountToBeUsed.update { + minOf(it + amount, gifticon.value?.balance ?: 0) + } + } ?: run { // "전액" 버튼이 클릭된 경우 + amountToBeUsed.update { + gifticon.value?.balance ?: return@run + } + } + } + + fun showOriginalImage() { + val origin = gifticon.value?.originPath ?: return + event(GifticonDetailEvent.ShowOriginalImage(origin)) + } + + fun showLargeBarcode() { + viewModelScope.launch { + val barcode = gifticon.value?.barcode ?: return@launch + event(GifticonDetailEvent.ShowLargeBarcode(barcode)) + } + } + + fun editBalance(newBalance: Int) { + tempGifticon.value?.let { + _tempGifticon.value = it.copy(balance = newBalance) + } + } + + fun updateLocationPermission(isLocationPermission: Boolean) { + hasLocationPermission.value = isLocationPermission + } + + private fun switchMode(mode: GifticonDetailMode) { + _mode.value = mode + _scrollDownChipLabel.value = when (_mode.value) { + GifticonDetailMode.UNUSED -> UIText.StringResource(R.string.gifticon_detail_scroll_down_chip_unused) + GifticonDetailMode.EDIT -> UIText.StringResource(R.string.gifticon_detail_scroll_down_chip_edit) + GifticonDetailMode.USED -> UIText.StringResource(R.string.gifticon_detail_scroll_down_chip_used) + } + _masterButtonLabel.value = when (_mode.value) { + GifticonDetailMode.UNUSED -> UIText.StringResource(R.string.gifticon_detail_unused_mode_button_text) + GifticonDetailMode.EDIT -> UIText.StringResource(R.string.gifticon_detail_edit_mode_button_text) + GifticonDetailMode.USED -> UIText.StringResource(R.string.gifticon_detail_used_mode_button_text) + } + } + + private fun checkEditValidation(): Boolean { + val temp = tempGifticon.value ?: return true + return (temp.name.trim().isBlank() || temp.brand.trim().isBlank()).not() + } + + private fun event(event: GifticonDetailEvent) { + viewModelScope.launch { + _event.emit(event) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/StandardGifticonInfoFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/StandardGifticonInfoFragment.kt new file mode 100644 index 000000000..35c66aa57 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/StandardGifticonInfoFragment.kt @@ -0,0 +1,22 @@ +package com.lighthouse.presentation.ui.detailgifticon + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentStandardGifticonInfoBinding +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.util.Geography + +class StandardGifticonInfoFragment : Fragment(R.layout.fragment_standard_gifticon_info) { + private val binding: FragmentStandardGifticonInfoBinding by viewBindings() + private val viewModel: GifticonDetailViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + binding.geo = Geography(requireContext()) + binding.lifecycleOwner = viewLifecycleOwner + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/LargeBarcodeDialog.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/LargeBarcodeDialog.kt new file mode 100644 index 000000000..a5bad1272 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/LargeBarcodeDialog.kt @@ -0,0 +1,62 @@ +package com.lighthouse.presentation.ui.detailgifticon.dialog + +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import androidx.fragment.app.DialogFragment +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.DialogLargeBarcodeBinding +import com.lighthouse.presentation.extension.rotated +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.util.BarcodeUtil +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class LargeBarcodeDialog : DialogFragment(R.layout.dialog_large_barcode) { + private val binding: DialogLargeBarcodeBinding by viewBindings() + private val barcode + get() = arguments?.getString(Extras.KEY_BARCODE) ?: run { + this.dismiss() + "" + } + + @Inject + lateinit var barcodeUtil: BarcodeUtil + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + binding.root.setOnClickListener { + dismiss() + } + + binding.barcode = barcode + binding.tvBarcodeNumber.text = divideBarcodeNumber(barcode) + binding.ivBarcode.viewTreeObserver.addOnDrawListener { + Timber.tag("barcode").d("width: ${binding.ivBarcode.width}, height: ${binding.ivBarcode.height}") + val rotatedBarcode = barcodeUtil.createBarcodeBitmap( + barcode, + binding.ivBarcode.height, + binding.ivBarcode.width + ).rotated(90f) + binding.ivBarcode.setImageBitmap(rotatedBarcode) + } + } + + override fun onResume() { + super.onResume() + + dialog?.window?.apply { + attributes = attributes.apply { + width = WindowManager.LayoutParams.MATCH_PARENT + height = WindowManager.LayoutParams.MATCH_PARENT + } + } + } + + private fun divideBarcodeNumber(number: String) = number.chunked(4).joinToString(" ") +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/UsageHistoryAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/UsageHistoryAdapter.kt new file mode 100644 index 000000000..575f5d4b2 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/UsageHistoryAdapter.kt @@ -0,0 +1,49 @@ +package com.lighthouse.presentation.ui.detailgifticon.dialog + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.domain.model.UsageHistory +import com.lighthouse.presentation.R +import com.lighthouse.presentation.adapter.BindableListAdapter +import com.lighthouse.presentation.databinding.ItemUsageHistoryBinding +import com.lighthouse.presentation.util.Geography + +class UsageHistoryAdapter : BindableListAdapter(diffUtil) { + + class UsageHistoryViewHolder(private val binding: ItemUsageHistoryBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(usageHistory: UsageHistory) { + binding.geo = Geography(binding.root.context) + binding.usage = usageHistory + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UsageHistoryViewHolder { + val view = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.item_usage_history, + parent, + false + ) + return UsageHistoryViewHolder(view) + } + + override fun onBindViewHolder(holder: UsageHistoryViewHolder, position: Int) { + return holder.bind(currentList[position]) + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UsageHistory, newItem: UsageHistory): Boolean { + return oldItem.date == newItem.date + } + + override fun areContentsTheSame(oldItem: UsageHistory, newItem: UsageHistory): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/UseGifticonDialog.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/UseGifticonDialog.kt new file mode 100644 index 000000000..030130ec4 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/detailgifticon/dialog/UseGifticonDialog.kt @@ -0,0 +1,83 @@ +package com.lighthouse.presentation.ui.detailgifticon.dialog + +import android.os.Bundle +import android.view.View +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.style.TextAlign +import androidx.fragment.app.activityViewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.DialogUseGifticonBinding +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extension.screenHeight +import com.lighthouse.presentation.ui.common.compose.ConcurrencyField +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.detailgifticon.GifticonDetailViewModel +import com.lighthouse.presentation.util.BarcodeUtil +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class UseGifticonDialog : BottomSheetDialogFragment(R.layout.dialog_use_gifticon) { + private val binding: DialogUseGifticonBinding by viewBindings() + private val viewModel: GifticonDetailViewModel by activityViewModels() + private var amountToUse = mutableStateOf(0) + + @Inject + lateinit var barcodeUtil: BarcodeUtil + + override fun getTheme(): Int { + return R.style.Theme_BEEP_BottomSheetDialog + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + binding.ctfAmountToBeUsed.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme { + ConcurrencyField( + value = amountToUse.value, + textStyle = MaterialTheme.typography.h4.copy(textAlign = TextAlign.End), + upperLimit = viewModel.gifticon.value?.balance ?: 0 + ) { + amountToUse.value = it + } + } + } + } + + initBottomSheetDialog(view) + + viewLifecycleOwner.repeatOnStarted { + viewModel.gifticon.collect { + val gifticon = it ?: return@collect + barcodeUtil.displayBitmap(binding.ivBarcode, gifticon.barcode) + binding.tvBarcodeNumber.text = divideBarcodeNumber(gifticon.barcode) + } + } + viewLifecycleOwner.repeatOnStarted { + viewModel.amountToBeUsed.collect { + amountToUse.value = it + } + } + } + + private fun initBottomSheetDialog(view: View) { + val bottomSheetBehavior = BottomSheetBehavior.from(view.parent as View) + bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + binding.layoutContainer.minHeight = (screenHeight * 0.9).toInt() + } + + private fun divideBarcodeNumber(number: String) = number.chunked(4).joinToString(" ") + + companion object { + const val TAG: String = "UseGifticonDialog" + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/AddGifticonActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/AddGifticonActivity.kt new file mode 100644 index 000000000..423e20c08 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/AddGifticonActivity.kt @@ -0,0 +1,346 @@ +package com.lighthouse.presentation.ui.edit.addgifticon + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.RectF +import android.net.Uri +import android.os.Bundle +import android.view.inputmethod.InputMethodManager +import androidx.activity.addCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivityAddGifticonBinding +import com.lighthouse.presentation.extension.dp +import com.lighthouse.presentation.extension.getParcelable +import com.lighthouse.presentation.extension.getParcelableArrayList +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extension.show +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.model.CroppedImage +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.ui.common.dialog.ConfirmationDialog +import com.lighthouse.presentation.ui.common.dialog.OriginImageDialog +import com.lighthouse.presentation.ui.common.dialog.ProgressDialog +import com.lighthouse.presentation.ui.common.dialog.datepicker.SpinnerDatePicker +import com.lighthouse.presentation.ui.cropgifticon.CropGifticonActivity +import com.lighthouse.presentation.ui.edit.addgifticon.adapter.AddGifticonAdapter +import com.lighthouse.presentation.ui.edit.addgifticon.adapter.AddGifticonItemUIModel +import com.lighthouse.presentation.ui.edit.addgifticon.event.AddGifticonCrop +import com.lighthouse.presentation.ui.edit.addgifticon.event.AddGifticonEvent +import com.lighthouse.presentation.ui.edit.addgifticon.event.AddGifticonTag +import com.lighthouse.presentation.ui.gallery.GalleryActivity +import com.lighthouse.presentation.util.recycler.ListSpaceItemDecoration +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@AndroidEntryPoint +class AddGifticonActivity : AppCompatActivity() { + + private lateinit var binding: ActivityAddGifticonBinding + + private val viewModel: AddGifticonViewModel by viewModels() + + private val gifticonAdapter = AddGifticonAdapter( + onClickGallery = { + viewModel.gotoGallery() + }, + onClickGifticon = { gifticon -> + viewModel.selectGifticon(gifticon) + }, + onDeleteGifticon = { gifticon -> + viewModel.showDeleteConfirmation(gifticon) + } + ) + + private val gallery = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val list = result.data?.getParcelableArrayList( + Extras.KEY_SELECTED_GALLERY_ITEM, + GalleryUIModel.Gallery::class.java + ) ?: emptyList() + viewModel.loadGalleryImages(list) + } + } + + private fun getCropResult(result: ActivityResult): CroppedImage? { + val croppedUri = result.data?.getParcelable(Extras.KEY_CROPPED_IMAGE, Uri::class.java) ?: return null + val croppedRect = result.data?.getParcelable(Extras.KEY_CROPPED_RECT, RectF::class.java) ?: return null + return CroppedImage(croppedUri, croppedRect) + } + + private suspend fun getCropResult(result: ActivityResult, id: Long): CroppedImage? { + return withContext(Dispatchers.IO) { + val outputFile = getFileStreamPath("$TEMP_GIFTICON_PREFIX$id") + val imageResult = getCropResult(result) + if (imageResult?.uri != null) { + FileInputStream(imageResult.uri.path).use { input -> + FileOutputStream(outputFile).use { output -> + input.copyTo(output) + } + } + CroppedImage(outputFile.toUri(), imageResult.croppedRect) + } else { + null + } + } + } + + private val cropGifticon = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + lifecycleScope.launch { + val gifticon = viewModel.selectedGifticon.value ?: return@launch + val croppedImage = getCropResult(result, gifticon.id) ?: return@launch + viewModel.updateCroppedGifticonImage(croppedImage) + } + } + + private val cropGifticonName = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeGifticonName(getCropResult(result)) + } + + private val cropBrandName = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeBrand(getCropResult(result)) + } + + private val cropBarcode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeBarcode(getCropResult(result)) + } + + private val cropBalance = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeBalance(getCropResult(result)) + } + + private val cropExpired = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeExpired(getCropResult(result)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_add_gifticon) + binding.apply { + lifecycleOwner = this@AddGifticonActivity + vm = viewModel + } + + setUpBackPressed() + setUpRecyclerView() + collectEvent() + } + + private fun setUpBackPressed() { + onBackPressedDispatcher.addCallback { + viewModel.requestPopBackstack() + } + } + + private fun setUpRecyclerView() { + binding.rvGifticon.apply { + adapter = gifticonAdapter + addItemDecoration(ListSpaceItemDecoration(4.dp, 32.dp, 0f, 32.dp, 0f)) + } + } + + private fun collectEvent() { + repeatOnStarted { + viewModel.eventFlow.collect { event -> + when (event) { + is AddGifticonEvent.PopupBackStack -> cancelAddGifticon() + is AddGifticonEvent.ShowCancelConfirmation -> showConfirmationCancelDialog() + is AddGifticonEvent.ShowDeleteConfirmation -> showConfirmationDeleteDialog(event.gifticon) + is AddGifticonEvent.NavigateToGallery -> gotoGallery(event.list) + is AddGifticonEvent.NavigateToCrop -> gotoCrop(event.crop, event.origin, event.croppedRect) + is AddGifticonEvent.ShowOriginGifticon -> showOriginGifticonDialog(event.origin) + is AddGifticonEvent.ShowExpiredAtDatePicker -> showExpiredAtDatePicker(event.date) + is AddGifticonEvent.RequestLoading -> requestLoading(event.loading) + is AddGifticonEvent.RequestFocus -> requestFocus(event.tag) + is AddGifticonEvent.RequestScroll -> requestScroll(event.tag) + is AddGifticonEvent.ShowSnackBar -> showSnackBar(event.uiText) + is AddGifticonEvent.RegistrationCompleted -> completeAddGifticon() + } + } + } + } + + private fun cancelAddGifticon() { + setResult(Activity.RESULT_CANCELED) + finish() + } + + private fun completeAddGifticon() { + setResult(Activity.RESULT_OK) + finish() + } + + private fun gotoGallery(list: List) { + val intent = Intent(this, GalleryActivity::class.java).apply { + putParcelableArrayListExtra( + Extras.KEY_SELECTED_GALLERY_ITEM, + ArrayList().apply { + addAll(list) + } + ) + } + gallery.launch(intent) + } + + private fun gotoCrop(crop: AddGifticonCrop, uri: Uri, croppedRect: RectF) { + val gotoCropLauncher = when (crop) { + AddGifticonCrop.GIFTICON_IMAGE -> cropGifticon + AddGifticonCrop.GIFTICON_NAME -> cropGifticonName + AddGifticonCrop.BRAND_NAME -> cropBrandName + AddGifticonCrop.BARCODE -> cropBarcode + AddGifticonCrop.BALANCE -> cropBalance + AddGifticonCrop.EXPIRED -> cropExpired + } + val intent = Intent(this, CropGifticonActivity::class.java).apply { + putExtra(Extras.KEY_ORIGIN_IMAGE, uri) + putExtra(Extras.KEY_CROPPED_RECT, croppedRect) + if (crop != AddGifticonCrop.GIFTICON_IMAGE) { + putExtra(Extras.KEY_ENABLE_ASPECT_RATIO, false) + } + } + gotoCropLauncher.launch(intent) + } + + private var originImageDialog: OriginImageDialog? = null + + private fun showOriginGifticonDialog(uri: Uri) { + if (originImageDialog?.isAdded == true) { + originImageDialog?.dismiss() + } + originImageDialog = OriginImageDialog().apply { + arguments = Bundle().apply { + putParcelable(Extras.KEY_ORIGIN_IMAGE, uri) + } + } + originImageDialog?.show(supportFragmentManager) + } + + private var spinnerDatePicker: SpinnerDatePicker? = null + + private fun showExpiredAtDatePicker(date: Date) { + if (spinnerDatePicker?.isAdded == true) { + spinnerDatePicker?.dismiss() + } + + spinnerDatePicker = SpinnerDatePicker().apply { + setDate(date) + setOnDatePickListener { year, month, dayOfMonth -> + val newDate = Calendar.getInstance(Locale.getDefault()).let { + it.set(year, month - 1, dayOfMonth) + it.time + } + viewModel.updateExpiredAt(newDate) + } + } + spinnerDatePicker?.show(supportFragmentManager) + } + + private var confirmationCancelDialog: ConfirmationDialog? = null + + private fun showConfirmationCancelDialog() { + if (confirmationCancelDialog?.isAdded == true) { + confirmationCancelDialog?.dismiss() + } + val title = getString(R.string.add_gifticon_confirmation_cancel_title) + val message = getString(R.string.add_gifticon_confirmation_cancel_message) + confirmationCancelDialog = ConfirmationDialog().apply { + setTitle(title) + setMessage(message) + setOnOkClickListener { + cancelAddGifticon() + } + } + confirmationCancelDialog?.show(supportFragmentManager, CONFIRMATION_CANCEL_DIALOG) + } + + private var confirmationDeleteDialog: ConfirmationDialog? = null + + private fun showConfirmationDeleteDialog(gifticon: AddGifticonItemUIModel.Gifticon) { + if (confirmationDeleteDialog?.isAdded == true) { + confirmationDeleteDialog?.dismiss() + } + val title = getString(R.string.add_gifticon_confirmation_delete_title) + confirmationDeleteDialog = ConfirmationDialog().apply { + setTitle(title) + setOnOkClickListener { + viewModel.deleteGifticon(gifticon) + } + } + confirmationDeleteDialog?.show(supportFragmentManager, CONFIRMATION_DELETE_DIALOG) + } + + private var progressDialog: ProgressDialog? = null + + private fun requestLoading(loading: Boolean) { + if (progressDialog?.isAdded == true) { + progressDialog?.dismiss() + } + progressDialog = if (loading) ProgressDialog() else null + progressDialog?.show(supportFragmentManager) + } + + private fun requestFocus(tag: AddGifticonTag) { + val focusView = when (tag) { + AddGifticonTag.GIFTICON_NAME -> binding.tietName + AddGifticonTag.BRAND_NAME -> binding.tietBrand + AddGifticonTag.BARCODE -> binding.tietBarcode + AddGifticonTag.BALANCE -> binding.tietBalance + else -> binding.clContainer + } + focusView.requestFocus() + val inputMethodService = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + if (tag.needKeyboard) { + inputMethodService.showSoftInput(focusView, 0) + } else { + inputMethodService.hideSoftInputFromWindow(currentFocus?.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) + } + } + + private fun requestScroll(tag: AddGifticonTag) { + val focusView = when (tag) { + AddGifticonTag.GIFTICON_NAME -> binding.tvName + AddGifticonTag.BRAND_NAME -> binding.tvBrand + AddGifticonTag.APPROVE_BRAND_NAME -> binding.tvApproveBrandNameDescription + AddGifticonTag.BARCODE -> binding.tvBarcode + AddGifticonTag.BALANCE -> binding.tvBalance + AddGifticonTag.APPROVE_GIFTICON_IMAGE -> binding.ivGifticonImage + else -> null + } ?: return + + val dir = if (binding.nsv.scrollY - focusView.top > 0) SCROLL_DIR_DOWN else SCROLL_DIR_UP + if (binding.nsv.canScrollVertically(dir)) { + binding.nsv.smoothScrollTo(0, focusView.top) + } + } + + private fun showSnackBar(uiText: UIText) { + Snackbar.make(binding.root, uiText.asString(applicationContext), Snackbar.LENGTH_SHORT).show() + } + + companion object { + private const val SCROLL_DIR_UP = 1 + private const val SCROLL_DIR_DOWN = -1 + + private const val CONFIRMATION_CANCEL_DIALOG = "Tag.ConfirmationCancelDialog" + private const val CONFIRMATION_DELETE_DIALOG = "Tag.ConfirmationDeleteDialog" + + private const val TEMP_GIFTICON_PREFIX = "temp_gifticon_" + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/AddGifticonViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/AddGifticonViewModel.kt new file mode 100644 index 000000000..377c08f02 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/AddGifticonViewModel.kt @@ -0,0 +1,903 @@ +package com.lighthouse.presentation.ui.edit.addgifticon + +import android.graphics.RectF +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.usecase.edit.HasGifticonBrandUseCase +import com.lighthouse.domain.usecase.edit.addgifticon.AddRecognizeUseCase +import com.lighthouse.domain.usecase.edit.addgifticon.SaveGifticonsUseCase +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.toDayOfMonth +import com.lighthouse.presentation.extension.toDigit +import com.lighthouse.presentation.extension.toMonth +import com.lighthouse.presentation.extension.toYear +import com.lighthouse.presentation.mapper.toAddGifticonItemUIModel +import com.lighthouse.presentation.mapper.toAddGifticonUIModel +import com.lighthouse.presentation.mapper.toDomain +import com.lighthouse.presentation.mapper.toGalleryUIModel +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.model.AddGifticonUIModel +import com.lighthouse.presentation.model.CroppedImage +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.ui.edit.addgifticon.adapter.AddGifticonItemUIModel +import com.lighthouse.presentation.ui.edit.addgifticon.event.AddGifticonCrop +import com.lighthouse.presentation.ui.edit.addgifticon.event.AddGifticonEvent +import com.lighthouse.presentation.ui.edit.addgifticon.event.AddGifticonTag +import com.lighthouse.presentation.ui.edit.addgifticon.event.AddGifticonValid +import com.lighthouse.presentation.util.flow.MutableEventFlow +import com.lighthouse.presentation.util.flow.asEventFlow +import com.lighthouse.presentation.util.resource.AnimInfo +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class AddGifticonViewModel @Inject constructor( + private val saveGifticonsUseCase: SaveGifticonsUseCase, + private val hasGifticonBrandUseCase: HasGifticonBrandUseCase, + private val addRecognizeUseCase: AddRecognizeUseCase +) : ViewModel() { + + private val today = Calendar.getInstance().let { + it.set(Calendar.HOUR, 0) + it.set(Calendar.MINUTE, 0) + it.set(Calendar.SECOND, 0) + it.time + } + + private val _eventFlow = MutableEventFlow() + val eventFlow = _eventFlow.asEventFlow() + + init { + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.NavigateToGallery()) + } + } + + private val _displayList = MutableStateFlow>(listOf(AddGifticonItemUIModel.Gallery)) + val displayList = _displayList.asStateFlow() + + private val gifticonList = MutableStateFlow>(emptyList()) + + val registeredSizeText = gifticonList.map { list -> + if (list.isNotEmpty()) { + UIText.StringResource(R.string.add_gifticon_registered, list.size) + } else { + UIText.Empty + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, UIText.Empty) + + private fun updateSelectedDisplayGifticon( + update: (AddGifticonItemUIModel.Gifticon) -> AddGifticonItemUIModel.Gifticon + ) { + updateDisplayGifticon(selectedGifticon.value?.id, update) + } + + private fun updateDisplayGifticon( + gifticonId: Long?, + update: (AddGifticonItemUIModel.Gifticon) -> AddGifticonItemUIModel.Gifticon + ) { + val index = displayList.value.indexOfFirst { + it is AddGifticonItemUIModel.Gifticon && it.id == gifticonId + } + if (index == -1) { + return + } + val oldList = _displayList.value + val oldItem = oldList[index] as? AddGifticonItemUIModel.Gifticon ?: return + val newItem = update(oldItem) + if (oldItem == newItem) { + return + } + _displayList.value = oldList.subList(0, index) + listOf(newItem) + oldList.subList(index + 1, oldList.size) + } + + private fun updateSelectedGifticon( + checkValid: Boolean = false, + update: (AddGifticonUIModel) -> AddGifticonUIModel + ) = updateGifticon(checkValid, selectedGifticon.value?.id, update) + + private fun updateGifticon( + checkValid: Boolean = false, + gifticonId: Long? = selectedGifticon.value?.id, + update: (AddGifticonUIModel) -> AddGifticonUIModel + ): AddGifticonUIModel? { + val index = gifticonList.value.indexOfFirst { it.id == gifticonId } + if (index == -1) { + return null + } + val oldList = gifticonList.value + val oldItem = oldList[index] + val newItem = update(oldItem) + if (oldItem == newItem) { + return null + } + gifticonList.value = oldList.subList(0, index) + listOf(newItem) + oldList.subList(index + 1, oldList.size) + + if (checkValid) { + updateDisplayGifticon(gifticonId) { it.copy(isValid = checkGifticonValid(newItem) == AddGifticonValid.VALID) } + } + return newItem + } + + private fun deleteDisplayGifticon(id: Long) { + val index = _displayList.value.indexOfFirst { + it is AddGifticonItemUIModel.Gifticon && it.id == id + } + if (index == -1) { + return + } + val oldList = _displayList.value + if (oldList[index] !is AddGifticonItemUIModel.Gifticon) { + return + } + _displayList.value = oldList.subList(0, index) + oldList.subList(index + 1, oldList.size) + } + + private fun deleteGifticon(id: Long) { + val index = gifticonList.value.indexOfFirst { it.id == id } + if (index == -1) { + return + } + val oldList = gifticonList.value + gifticonList.value = oldList.subList(0, index) + oldList.subList(index + 1, oldList.size) + } + + fun deleteGifticon(gifticon: AddGifticonItemUIModel.Gifticon) { + if (selectedGifticon.value?.id == gifticon.id) { + val oldList = gifticonList.value + val index = oldList.indexOfFirst { it.id == selectedGifticon.value?.id } + val id = when { + index + 1 < oldList.size -> oldList[index + 1].id + index - 1 >= 0 -> oldList[index - 1].id + else -> -1 + } + selectGifticonId(id) + } + deleteDisplayGifticon(gifticon.id) + deleteGifticon(gifticon.id) + } + + private val _isDeleteMode = MutableStateFlow(false) + + fun changeDeleteMode() { + changeDeleteMode(_isDeleteMode.value.not()) + } + + private fun changeDeleteMode(newDeleteMode: Boolean) { + _isDeleteMode.value = newDeleteMode + _displayList.value = _displayList.value.map { + if (it is AddGifticonItemUIModel.Gifticon) { + it.copy(isDelete = newDeleteMode) + } else { + it + } + } + } + + val deleteModeText = _isDeleteMode.map { isDeleteMode -> + if (isDeleteMode) { + UIText.StringResource(R.string.add_gifticon_edit_mode) + } else { + UIText.StringResource(R.string.add_gifticon_delete_mode) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, UIText.Empty) + + private var selectedId = MutableSharedFlow() + + private fun selectGifticonId(id: Long?) { + id ?: return + viewModelScope.launch { + selectedId.emit(id) + } + } + + fun selectGifticon(gifticon: AddGifticonItemUIModel.Gifticon) { + selectGifticonId(gifticon.id) + } + + val displayName = MutableStateFlow("") + val displayBrand = MutableStateFlow("") + + val selectedGifticon = selectedId.onEach { selectedId -> + _displayList.value = displayList.value.map { + if (it is AddGifticonItemUIModel.Gifticon) { + it.copy(isSelected = it.id == selectedId) + } else { + it + } + } + val gifticon = gifticonList.value.find { it.id == selectedId } + displayName.value = gifticon?.name ?: "" + displayBrand.value = gifticon?.brandName ?: "" + }.combine(gifticonList) { id, list -> + list.find { it.id == id } + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val expiredAtDate: Date? + get() = selectedGifticon.value?.expiredAt?.let { + if (it == EMPTY_DATE) today else it + } + + val isSelected = selectedGifticon.map { + it != null + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val gifticonImage = selectedGifticon.map { + it?.gifticonImage + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val displayGifticonImage = selectedGifticon.map { + it?.uri + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun updateCroppedGifticonImage(croppedImage: CroppedImage) { + val updated = updateSelectedGifticon { it.copy(gifticonImage = croppedImage) } ?: return + updateSelectedDisplayGifticon { + it.copy(thumbnailImage = croppedImage, isValid = checkGifticonValid(updated) == AddGifticonValid.VALID) + } + } + + private val isApproveGifticonImage = selectedGifticon.map { + it?.approveGifticonImage ?: false + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isApproveGifticonImageDescriptionVisible = gifticonImage.combine(isApproveGifticonImage) { image, isApprove -> + if (image == null) false else image.uri == null && isApprove.not() + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun approveGifticonImage() { + updateSelectedGifticon(true) { + it.copy(approveGifticonImage = true) + } + } + + val isCashCard = selectedGifticon.map { + it?.isCashCard + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun updateCashCard(checked: Boolean) { + updateSelectedGifticon(true) { it.copy(isCashCard = checked) } + } + + private val name = selectedGifticon.map { + it?.name + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun updateGifticonName(name: CharSequence) { + updateSelectedGifticon(true) { it.copy(name = name.toString()) } + } + + private val nameFocus = MutableStateFlow(false) + + fun onNameFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(AddGifticonTag.GIFTICON_NAME) + } + nameFocus.value = hasFocus + } + + val nameRemoveVisible = name.combine(nameFocus) { name, focus -> + !name.isNullOrEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeName() { + updateGifticonName("") + } + + private val brand = selectedGifticon.map { + it?.brandName ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private val confirmedBrandMap = hashMapOf() + + private val approveBrandName = selectedGifticon.map { + it?.approveBrandName ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private var hasGifticonBrandJob: Job? = null + + private fun checkHasGifticonBrand(brand: String) { + hasGifticonBrandJob?.cancel() + if (brand.isNotEmpty() && brand != approveBrandName.value) { + hasGifticonBrandJob = viewModelScope.launch { + isLoadingConfirmBrand.value = true + delay(1000) + val approve = confirmedBrandMap[brand] ?: run { + hasGifticonBrandUseCase(brand).also { + confirmedBrandMap[brand] = it + } + } + updateApproveBrandName(if (approve) brand else "") + } + hasGifticonBrandJob?.invokeOnCompletion { + isLoadingConfirmBrand.value = false + } + } + } + + fun updateBrandName(brandName: CharSequence) { + updateSelectedGifticon(true) { it.copy(brandName = brandName.toString()) } + checkHasGifticonBrand(brandName.toString()) + } + + private fun updateApproveBrandName(approveBrandName: String) { + updateSelectedGifticon(true) { + it.copy(approveBrandName = approveBrandName) + } + } + + private val isApproveBrandName = brand.combine(approveBrandName) { brand, approveBrand -> + brand == approveBrand + } + + private val brandFocus = MutableStateFlow(false) + + fun onBrandFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(AddGifticonTag.BRAND_NAME) + } + brandFocus.value = hasFocus + } + + val brandRemoveVisible = brand.combine(brandFocus) { brand, focus -> + brand.isNotEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeBrand() { + updateBrandName("") + } + + val isLoadingConfirmBrand = MutableStateFlow(false) + + private val isApproveBrandNameVisible = brand.combine(isLoadingConfirmBrand) { brand, isLoading -> + brand != "" && !isLoading + } + + val isApproveBrandNameVisibility = isApproveBrandNameVisible.map { isVisible -> + if (isVisible) View.VISIBLE else View.INVISIBLE + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveBrandNameDescriptionVisible = + isApproveBrandName.combine(isApproveBrandNameVisible) { isApprove, isVisible -> + isApprove.not() && isVisible + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isApproveBrandNameResId = isApproveBrandName.map { isApprove -> + if (isApprove) R.drawable.ic_confirm else R.drawable.ic_question + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveBrandNameTint = isApproveBrandName.map { isApprove -> + if (isApprove) R.color.point_green else R.color.yellow + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveBrandNameAnimation = isApproveBrandName.combine(isApproveBrandNameVisible) { isApprove, isVisible -> + if (isApprove) { + AnimInfo.AnimResource(R.anim.anim_fadein_up, isVisible) + } else { + AnimInfo.AnimResource(R.anim.anim_jump, isVisible) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, AnimInfo.Empty) + + fun approveBrandName() { + val approveBrandName = brand.value + confirmedBrandMap[approveBrandName] = true + gifticonList.value.forEach { gifticon -> + if (gifticon.brandName != approveBrandName) { + return@forEach + } + updateGifticon(true, gifticon.id) { + it.copy(approveBrandName = approveBrandName) + } + } + } + + val barcode = selectedGifticon.map { + it?.barcode ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private fun updateBarcode(barcode: String) { + updateSelectedGifticon(true) { + it.copy(barcode = barcode) + } + } + + fun changeBarcode(value: String) { + updateBarcode(value) + } + + private val barcodeFocus = MutableStateFlow(false) + + fun onBarcodeFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(AddGifticonTag.BARCODE) + } + barcodeFocus.value = hasFocus + } + + val barcodeRemoveVisible = barcode.combine(barcodeFocus) { barcode, focus -> + barcode.isNotEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeBarcode() { + updateBarcode("") + } + + val balance = selectedGifticon.map { + it?.balance ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private fun updateBalance(balance: String) { + updateSelectedGifticon(true) { + it.copy(balance = balance) + } + } + + fun changeBalance(value: String) { + updateBalance(value) + } + + private val balanceFocus = MutableStateFlow(false) + + fun onBalanceFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(AddGifticonTag.BALANCE) + } + balanceFocus.value = hasFocus + } + + val balanceRemoveVisible = balance.combine(balanceFocus) { balance, focus -> + balance != "0" && balance.isNotEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeBalance() { + updateBalance("") + } + + private val expiredAt = selectedGifticon.map { + it?.expiredAt ?: EMPTY_DATE + }.stateIn(viewModelScope, SharingStarted.Eagerly, EMPTY_DATE) + + val expiredAtUIText = expiredAt.map { date -> + if (date != EMPTY_DATE) { + UIText.StringResource(R.string.all_date, date.toYear(), date.toMonth(), date.toDayOfMonth()) + } else { + UIText.Empty + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, UIText.Empty) + + private val approveExpiredAt = selectedGifticon.map { + it?.approveExpiredAt ?: false + } + + fun updateExpiredAt(expiredAt: Date) { + updateSelectedGifticon(true) { it.copy(expiredAt = expiredAt) } + } + + private val isApproveExpired = expiredAt.combine(approveExpiredAt) { expiredAt, approve -> + expiredAt >= today || approve + } + + val isApproveExpiredAtDescriptionVisible = expiredAt.combine(approveExpiredAt) { expiredAt, approve -> + expiredAt != EMPTY_DATE && (expiredAt < today && approve.not()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isApproveExpiredAtVisible = expiredAt.map { expiredAt -> + expiredAt != EMPTY_DATE + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isApproveExpiredAtResId = isApproveExpired.map { isApprove -> + if (isApprove) R.drawable.ic_confirm else R.drawable.ic_question + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveExpiredAtTint = isApproveExpired.map { isApprove -> + if (isApprove) R.color.point_green else R.color.yellow + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveExpiredAtAnimation = isApproveExpired.combine(isApproveExpiredAtVisible) { isApprove, isVisible -> + if (isApprove) { + AnimInfo.AnimResource(R.anim.anim_fadein_up, isVisible) + } else { + AnimInfo.AnimResource(R.anim.anim_jump, isVisible) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, AnimInfo.Empty) + + fun approveExpiredAt() { + updateSelectedGifticon(true) { + it.copy(approveExpiredAt = true) + } + } + + val memo = selectedGifticon.map { + it?.memo + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun updateMemo(memo: CharSequence) { + updateSelectedGifticon { it.copy(memo = memo.toString()) } + } + + fun onActionNextListener(actionId: Int): Boolean { + val gifticon = selectedGifticon.value ?: return false + if (actionId == EditorInfo.IME_ACTION_NEXT) { + val event = when (checkGifticonValid(gifticon)) { + AddGifticonValid.INVALID_GIFTICON_NAME -> AddGifticonEvent.RequestFocus(AddGifticonTag.GIFTICON_NAME) + AddGifticonValid.INVALID_BRAND_NAME -> AddGifticonEvent.RequestFocus(AddGifticonTag.BRAND_NAME) + AddGifticonValid.INVALID_BARCODE -> AddGifticonEvent.RequestFocus(AddGifticonTag.BARCODE) + AddGifticonValid.INVALID_BALANCE -> AddGifticonEvent.RequestFocus(AddGifticonTag.BALANCE) + AddGifticonValid.INVALID_EXPIRED_AT -> AddGifticonEvent.ShowExpiredAtDatePicker(expiredAtDate ?: today) + else -> { + requestAddGifticon() + return true + } + } + viewModelScope.launch { + _eventFlow.emit(event) + } + return true + } + return false + } + + fun loadGalleryImages(list: List) { + val oldDisplayList = _displayList.value + _displayList.value = listOf(AddGifticonItemUIModel.Gallery) + list.map { newItem -> + oldDisplayList.find { oldItem -> oldItem is AddGifticonItemUIModel.Gifticon && newItem.id == oldItem.id } + ?: newItem.toAddGifticonItemUIModel() + } + + val oldGifticonList = gifticonList.value + gifticonList.value = list.map { newItem -> + oldGifticonList.find { oldItem -> newItem.id == oldItem.id } ?: newItem.toAddGifticonUIModel() + } + + val newList = list.filter { newItem -> + oldGifticonList.none { oldItem -> + oldItem.id == newItem.id + } + } + + selectGifticonId(newList.getOrNull(0)?.id) + + recognizeGifticonList(newList) + } + + private fun recognizeGifticonList(list: List) { + if (list.isEmpty()) { + return + } + + requestLoading(true) + viewModelScope.launch { + launch { + list.forEach { gallery -> + launch { + recognizeGifticonItem(gallery) + } + } + }.join() + requestLoading(false) + } + } + + private suspend fun recognizeGifticonItem(gallery: GalleryUIModel.Gallery) { + val result = addRecognizeUseCase.gifticon(gallery.toDomain()) ?: return + var approveBrandName = "" + if (result.brandName != "" && hasGifticonBrandUseCase(result.brandName)) { + approveBrandName = result.brandName + } + val updated = updateGifticon(gifticonId = gallery.id) { + result.toPresentation( + id = gallery.id, + createdDate = gallery.createdDate, + approveBrandName = approveBrandName + ) + } ?: return + updateDisplayGifticon(gallery.id) { + it.copy( + thumbnailImage = updated.gifticonImage, + isValid = checkGifticonValid(updated) == AddGifticonValid.VALID + ) + } + if (updated.id == selectedGifticon.value?.id) { + selectGifticonId(updated.id) + } + } + + fun recognizeGifticonName(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = addRecognizeUseCase.gifticonName(uri.toString()) + if (result != "") { + updateGifticon(true) { it.copy(name = result, nameRectF = croppedImage.croppedRect) } + } else { + updateGifticon { it.copy(nameRectF = croppedImage.croppedRect) } + _eventFlow.emit(AddGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_name))) + } + selectGifticonId(selectedGifticon.value?.id) + } + } + + fun recognizeBrand(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = addRecognizeUseCase.brandName(uri.toString()) + if (result != "") { + updateGifticon(true) { it.copy(brandName = result, brandNameRectF = croppedImage.croppedRect) } + } else { + updateGifticon { it.copy(brandNameRectF = croppedImage.croppedRect) } + _eventFlow.emit(AddGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_brand))) + } + selectGifticonId(selectedGifticon.value?.id) + } + } + + fun recognizeBarcode(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = addRecognizeUseCase.barcode(uri.toString()) + if (result != "") { + updateGifticon(true) { it.copy(barcode = result, barcodeRectF = croppedImage.croppedRect) } + } else { + updateGifticon { it.copy(barcodeRectF = croppedImage.croppedRect) } + _eventFlow.emit(AddGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_barcode))) + } + selectGifticonId(selectedGifticon.value?.id) + } + } + + fun recognizeBalance(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = addRecognizeUseCase.balance(uri.toString()) + if (result > 0) { + updateGifticon(true) { + it.copy(isCashCard = true, balance = result.toString(), balanceRectF = croppedImage.croppedRect) + } + } else { + updateGifticon { it.copy(balanceRectF = croppedImage.croppedRect) } + _eventFlow.emit(AddGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_balance))) + } + selectGifticonId(selectedGifticon.value?.id) + } + } + + fun recognizeExpired(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = addRecognizeUseCase.expired(uri.toString()) + if (result != EMPTY_DATE) { + updateGifticon(true) { it.copy(expiredAt = result, expiredAtRectF = croppedImage.croppedRect) } + } else { + updateGifticon { it.copy(expiredAtRectF = croppedImage.croppedRect) } + _eventFlow.emit(AddGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_expired_at))) + } + selectGifticonId(selectedGifticon.value?.id) + } + } + + private fun checkGifticonValid(gifticon: AddGifticonUIModel): AddGifticonValid { + return when { + gifticon.name.isEmpty() -> AddGifticonValid.INVALID_GIFTICON_NAME + gifticon.brandName.isEmpty() -> AddGifticonValid.INVALID_BRAND_NAME + gifticon.brandName != gifticon.approveBrandName -> AddGifticonValid.INVALID_APPROVE_BRAND_NAME + gifticon.barcode.length !in VALID_BARCODE_COUNT -> AddGifticonValid.INVALID_BARCODE + gifticon.isCashCard && gifticon.balance.toDigit() == 0 -> AddGifticonValid.INVALID_BALANCE + gifticon.expiredAt == EMPTY_DATE -> AddGifticonValid.INVALID_EXPIRED_AT + gifticon.expiredAt < today && gifticon.approveExpiredAt.not() -> AddGifticonValid.INVALID_APPROVE_EXPIRED_AT + gifticon.gifticonImage.uri == null && gifticon.approveGifticonImage.not() -> AddGifticonValid.INVALID_APPROVE_GIFTICON_IMAGE + else -> AddGifticonValid.VALID + } + } + + private fun handleGifticonInvalid(valid: AddGifticonValid) { + viewModelScope.launch { + when (valid) { + AddGifticonValid.VALID -> {} + else -> { + _eventFlow.emit(AddGifticonEvent.ShowSnackBar(valid.text)) + _eventFlow.emit(AddGifticonEvent.RequestFocus(valid.tag)) + _eventFlow.emit(AddGifticonEvent.RequestScroll(valid.tag)) + } + } + } + } + + fun requestCashCard() { + val gifticon = selectedGifticon.value ?: return + viewModelScope.launch { + val event = if (gifticon.isCashCard) { + AddGifticonEvent.RequestFocus(AddGifticonTag.BALANCE) + } else { + AddGifticonEvent.RequestFocus(AddGifticonTag.NONE) + } + _eventFlow.emit(event) + } + } + + fun requestAddGifticon() { + viewModelScope.launch { + var valid: AddGifticonValid = AddGifticonValid.VALID + if (gifticonList.value.isEmpty()) { + valid = AddGifticonValid.INVALID_EMPTY + } else { + for (gifticon in gifticonList.value) { + valid = checkGifticonValid(gifticon) + if (valid != AddGifticonValid.VALID) { + selectGifticonId(gifticon.id) + break + } + } + } + when (valid) { + AddGifticonValid.VALID -> { + val gifticons = gifticonList.value.map { + it.toDomain() + } + saveGifticonsUseCase(gifticons) + _eventFlow.emit(AddGifticonEvent.RegistrationCompleted) + } + else -> { + handleGifticonInvalid(valid) + } + } + } + } + + fun requestPopBackstack() { + if (gifticonList.value.isEmpty()) { + popBackstack() + } else { + showCancelConfirmation() + } + } + + private fun popBackstack() { + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestFocus(AddGifticonTag.NONE)) + _eventFlow.emit(AddGifticonEvent.PopupBackStack) + } + } + + private fun showCancelConfirmation() { + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestFocus(AddGifticonTag.NONE)) + _eventFlow.emit(AddGifticonEvent.ShowCancelConfirmation) + } + } + + fun showDeleteConfirmation(gifticon: AddGifticonItemUIModel.Gifticon) { + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestFocus(AddGifticonTag.NONE)) + _eventFlow.emit(AddGifticonEvent.ShowDeleteConfirmation(gifticon)) + } + } + + fun gotoGallery() { + changeDeleteMode(false) + + val list = gifticonList.value.mapIndexed { index, gifticon -> + gifticon.toGalleryUIModel(index + 1) + } + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestFocus(AddGifticonTag.NONE)) + _eventFlow.emit(AddGifticonEvent.NavigateToGallery(list)) + } + } + + fun gotoCropGifticonImage() { + val gifticon = selectedGifticon.value ?: return + gotoCropGifticon( + crop = AddGifticonCrop.GIFTICON_IMAGE, + croppedRect = gifticon.gifticonImage.croppedRect + ) + } + + fun gotoCropGifticonName() { + val gifticon = selectedGifticon.value ?: return + gotoCropGifticon( + crop = AddGifticonCrop.GIFTICON_NAME, + croppedRect = gifticon.nameRectF + ) + } + + fun gotoCropBrandName() { + val gifticon = selectedGifticon.value ?: return + gotoCropGifticon( + crop = AddGifticonCrop.BRAND_NAME, + croppedRect = gifticon.brandNameRectF + ) + } + + fun gotoCropBarcode() { + val gifticon = selectedGifticon.value ?: return + gotoCropGifticon( + crop = AddGifticonCrop.BARCODE, + croppedRect = gifticon.barcodeRectF + ) + } + + fun gotoCropBalance() { + val gifticon = selectedGifticon.value ?: return + gotoCropGifticon( + crop = AddGifticonCrop.BALANCE, + croppedRect = gifticon.balanceRectF + ) + } + + fun gotoCropExpired() { + val gifticon = selectedGifticon.value ?: return + gotoCropGifticon( + crop = AddGifticonCrop.EXPIRED, + croppedRect = gifticon.expiredAtRectF + ) + } + + private fun gotoCropGifticon( + crop: AddGifticonCrop, + croppedRect: RectF = RectF() + ) { + val originUri = selectedGifticon.value?.origin ?: return + changeDeleteMode(false) + + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestFocus(AddGifticonTag.NONE)) + _eventFlow.emit( + AddGifticonEvent.NavigateToCrop(crop, originUri, croppedRect) + ) + } + } + + fun showOriginGifticon() { + val originUri = selectedGifticon.value?.origin ?: return + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestFocus(AddGifticonTag.NONE)) + _eventFlow.emit(AddGifticonEvent.ShowOriginGifticon(originUri)) + } + } + + fun showExpiredAtDatePicker() { + val expiredAt = expiredAtDate ?: return + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestFocus(AddGifticonTag.NONE)) + _eventFlow.emit(AddGifticonEvent.ShowExpiredAtDatePicker(expiredAt)) + } + } + + private fun requestLoading(loading: Boolean) { + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestLoading(loading)) + } + } + + private fun requestScroll(tag: AddGifticonTag) { + viewModelScope.launch { + _eventFlow.emit(AddGifticonEvent.RequestScroll(tag)) + } + } + + companion object { + private val EMPTY_DATE = Date(0) + + private val VALID_BARCODE_COUNT = setOf(12, 14, 16, 18, 20, 22, 24) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddCandidateGifticonDisplayModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddCandidateGifticonDisplayModel.kt new file mode 100644 index 000000000..7108b5545 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddCandidateGifticonDisplayModel.kt @@ -0,0 +1,22 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.adapter + +class AddCandidateGifticonDisplayModel( + var item: AddGifticonItemUIModel.Gifticon, + private val onClick: (AddGifticonItemUIModel.Gifticon) -> Unit, + private val onDelete: (AddGifticonItemUIModel.Gifticon) -> Unit +) { + + val deleteVisible + get() = item.isDelete + + val invalidVisible + get() = item.isDelete.not() && item.isValid.not() + + fun onClickItem() { + if (item.isDelete) { + onDelete(item) + } else { + onClick(item) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddCandidateGifticonViewHolder.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddCandidateGifticonViewHolder.kt new file mode 100644 index 000000000..41dbf0f98 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddCandidateGifticonViewHolder.kt @@ -0,0 +1,36 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ItemAddCandidateGifticonBinding + +class AddCandidateGifticonViewHolder( + parent: ViewGroup, + private val onClick: (AddGifticonItemUIModel.Gifticon) -> Unit, + private val onDelete: (AddGifticonItemUIModel.Gifticon) -> Unit, + private val binding: ItemAddCandidateGifticonBinding = ItemAddCandidateGifticonBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_add_candidate_gifticon, parent, false) + ) +) : RecyclerView.ViewHolder(binding.root) { + + private var dm: AddCandidateGifticonDisplayModel? = null + + fun bind(item: AddGifticonItemUIModel.Gifticon) { + dm = AddCandidateGifticonDisplayModel(item, onClick, onDelete) + binding.dm = dm + } + + fun bindBadge(item: AddGifticonItemUIModel.Gifticon) { + dm?.item = item + binding.ivDelete.isVisible = dm?.deleteVisible ?: false + binding.ivInvalid.isVisible = dm?.invalidVisible ?: false + } + + fun bindSelected(item: AddGifticonItemUIModel.Gifticon) { + dm?.item = item + binding.viewCandidateRippleEffect.isSelected = item.isSelected + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGifticonAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGifticonAdapter.kt new file mode 100644 index 000000000..887bec7c2 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGifticonAdapter.kt @@ -0,0 +1,96 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.adapter.BindableListAdapter + +class AddGifticonAdapter( + private val onClickGallery: () -> Unit, + private val onClickGifticon: (AddGifticonItemUIModel.Gifticon) -> Unit, + private val onDeleteGifticon: (AddGifticonItemUIModel.Gifticon) -> Unit +) : BindableListAdapter(diff) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + TYPE_GALLERY -> AddGotoGalleryViewHolder(parent, onClickGallery) + TYPE_GIFTICON -> AddCandidateGifticonViewHolder(parent, onClickGifticon, onDeleteGifticon) + else -> throw IllegalArgumentException("잘못된 viewType 입니다.") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = currentList[position] + when { + holder is AddCandidateGifticonViewHolder && item is AddGifticonItemUIModel.Gifticon -> { + holder.bind(item) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList) { + if (payloads.isNotEmpty()) { + val flag = payloads.getOrNull(0) as? Int ?: 0 + val item = getItem(position) + + if (holder is AddCandidateGifticonViewHolder && item is AddGifticonItemUIModel.Gifticon) { + if (flag and UPDATE_BADGE == UPDATE_BADGE) { + holder.bindBadge(item) + } + if (flag and UPDATE_SELECTED == UPDATE_SELECTED) { + holder.bindSelected(item) + } + } + } else { + onBindViewHolder(holder, position) + } + } + + override fun getItemViewType(position: Int): Int { + return when (currentList[position]) { + AddGifticonItemUIModel.Gallery -> TYPE_GALLERY + is AddGifticonItemUIModel.Gifticon -> TYPE_GIFTICON + } + } + + companion object { + private val diff = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AddGifticonItemUIModel, newItem: AddGifticonItemUIModel): Boolean { + return when { + oldItem is AddGifticonItemUIModel.Gallery && newItem is AddGifticonItemUIModel.Gallery -> true + oldItem is AddGifticonItemUIModel.Gifticon && newItem is AddGifticonItemUIModel.Gifticon -> { + oldItem.id == newItem.id + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: AddGifticonItemUIModel, newItem: AddGifticonItemUIModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: AddGifticonItemUIModel, newItem: AddGifticonItemUIModel): Any? { + if (oldItem is AddGifticonItemUIModel.Gifticon && + newItem is AddGifticonItemUIModel.Gifticon && + newItem.thumbnailImage == oldItem.thumbnailImage + ) { + var flag = 0 + if (newItem.isDelete != oldItem.isDelete || newItem.isValid != oldItem.isValid) { + flag = flag xor UPDATE_BADGE + } + if (newItem.isSelected != oldItem.isSelected) { + flag = flag xor UPDATE_SELECTED + } + return if (flag != 0) flag else null + } + return null + } + } + + private const val TYPE_GALLERY = 1 + private const val TYPE_GIFTICON = 2 + + private const val UPDATE_BADGE = 1 shl 0 + private const val UPDATE_SELECTED = 1 shl 1 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGifticonItemUIModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGifticonItemUIModel.kt new file mode 100644 index 000000000..3c85a731e --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGifticonItemUIModel.kt @@ -0,0 +1,20 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.adapter + +import android.net.Uri +import com.lighthouse.presentation.model.CroppedImage + +sealed class AddGifticonItemUIModel { + object Gallery : AddGifticonItemUIModel() + data class Gifticon( + val id: Long, + val origin: Uri, + val thumbnailImage: CroppedImage, + val isSelected: Boolean, + val isDelete: Boolean, + val isValid: Boolean + ) : AddGifticonItemUIModel() { + + val uri: Uri + get() = thumbnailImage.uri ?: origin + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGotoGalleryViewHolder.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGotoGalleryViewHolder.kt new file mode 100644 index 000000000..6aff2bd52 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/adapter/AddGotoGalleryViewHolder.kt @@ -0,0 +1,22 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ItemAddGotoGalleryBinding + +class AddGotoGalleryViewHolder( + parent: ViewGroup, + onClick: () -> Unit, + binding: ItemAddGotoGalleryBinding = ItemAddGotoGalleryBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_add_goto_gallery, parent, false) + ) +) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.viewGotoGalleryRippleEffect.setOnClickListener { + onClick() + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonCrop.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonCrop.kt new file mode 100644 index 000000000..771999cb6 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonCrop.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.event + +enum class AddGifticonCrop { + GIFTICON_IMAGE, + GIFTICON_NAME, + BRAND_NAME, + BARCODE, + BALANCE, + EXPIRED +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonEvent.kt new file mode 100644 index 000000000..a5a13135f --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonEvent.kt @@ -0,0 +1,26 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.event + +import android.graphics.RectF +import android.net.Uri +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.ui.edit.addgifticon.adapter.AddGifticonItemUIModel +import com.lighthouse.presentation.util.resource.UIText +import java.util.Date + +sealed class AddGifticonEvent { + + object PopupBackStack : AddGifticonEvent() + object ShowCancelConfirmation : AddGifticonEvent() + object RegistrationCompleted : AddGifticonEvent() + data class ShowDeleteConfirmation(val gifticon: AddGifticonItemUIModel.Gifticon) : AddGifticonEvent() + data class NavigateToGallery(val list: List = emptyList()) : AddGifticonEvent() + data class NavigateToCrop(val crop: AddGifticonCrop, val origin: Uri, val croppedRect: RectF = RectF()) : + AddGifticonEvent() + + data class ShowOriginGifticon(val origin: Uri) : AddGifticonEvent() + data class ShowExpiredAtDatePicker(val date: Date) : AddGifticonEvent() + data class RequestLoading(val loading: Boolean) : AddGifticonEvent() + data class RequestFocus(val tag: AddGifticonTag) : AddGifticonEvent() + data class RequestScroll(val tag: AddGifticonTag) : AddGifticonEvent() + data class ShowSnackBar(val uiText: UIText) : AddGifticonEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonTag.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonTag.kt new file mode 100644 index 000000000..c14892221 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonTag.kt @@ -0,0 +1,11 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.event + +enum class AddGifticonTag(val needKeyboard: Boolean) { + GIFTICON_NAME(true), + BRAND_NAME(true), + APPROVE_BRAND_NAME(false), + BARCODE(true), + BALANCE(true), + APPROVE_GIFTICON_IMAGE(false), + NONE(false) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonValid.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonValid.kt new file mode 100644 index 000000000..d8cd2670c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/addgifticon/event/AddGifticonValid.kt @@ -0,0 +1,44 @@ +package com.lighthouse.presentation.ui.edit.addgifticon.event + +import com.lighthouse.presentation.R +import com.lighthouse.presentation.util.resource.UIText + +enum class AddGifticonValid(val tag: AddGifticonTag, val text: UIText) { + INVALID_EMPTY( + AddGifticonTag.NONE, + UIText.StringResource(R.string.edit_gifticon_invalid_empty) + ), + INVALID_GIFTICON_NAME( + AddGifticonTag.GIFTICON_NAME, + UIText.StringResource(R.string.edit_gifticon_invalid_gifticon_name) + ), + INVALID_BRAND_NAME( + AddGifticonTag.BRAND_NAME, + UIText.StringResource(R.string.edit_gifticon_invalid_brand_name) + ), + INVALID_APPROVE_BRAND_NAME( + AddGifticonTag.APPROVE_BRAND_NAME, + UIText.StringResource(R.string.edit_gifticon_invalid_approve_brand_name) + ), + INVALID_BARCODE( + AddGifticonTag.BARCODE, + UIText.StringResource(R.string.edit_gifticon_invalid_barcode) + ), + INVALID_EXPIRED_AT( + AddGifticonTag.NONE, + UIText.StringResource(R.string.edit_gifticon_invalid_expired_at) + ), + INVALID_APPROVE_EXPIRED_AT( + AddGifticonTag.NONE, + UIText.StringResource(R.string.edit_gifticon_invalid_approve_expired_at) + ), + INVALID_BALANCE( + AddGifticonTag.BALANCE, + UIText.StringResource(R.string.edit_gifticon_invalid_balance) + ), + INVALID_APPROVE_GIFTICON_IMAGE( + AddGifticonTag.APPROVE_GIFTICON_IMAGE, + UIText.StringResource(R.string.edit_gifticon_invalid_approve_gifticon_image) + ), + VALID(AddGifticonTag.NONE, UIText.Empty) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/ModifyGifticonActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/ModifyGifticonActivity.kt new file mode 100644 index 000000000..e55e80ebb --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/ModifyGifticonActivity.kt @@ -0,0 +1,265 @@ +package com.lighthouse.presentation.ui.edit.modifygifticon + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.RectF +import android.net.Uri +import android.os.Bundle +import android.view.inputmethod.InputMethodManager +import androidx.activity.addCallback +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivityModifyGifticonBinding +import com.lighthouse.presentation.extension.getParcelable +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extension.show +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.model.CroppedImage +import com.lighthouse.presentation.ui.common.dialog.ConfirmationDialog +import com.lighthouse.presentation.ui.common.dialog.OriginImageDialog +import com.lighthouse.presentation.ui.common.dialog.datepicker.SpinnerDatePicker +import com.lighthouse.presentation.ui.cropgifticon.CropGifticonActivity +import com.lighthouse.presentation.ui.edit.modifygifticon.event.ModifyGifticonCrop +import com.lighthouse.presentation.ui.edit.modifygifticon.event.ModifyGifticonEvent +import com.lighthouse.presentation.ui.edit.modifygifticon.event.ModifyGifticonTag +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@AndroidEntryPoint +class ModifyGifticonActivity : AppCompatActivity() { + + private lateinit var binding: ActivityModifyGifticonBinding + + private val viewModel: ModifyGifticonViewModel by viewModels() + + private fun getCropResult(result: ActivityResult): CroppedImage? { + val croppedUri = result.data?.getParcelable(Extras.KEY_CROPPED_IMAGE, Uri::class.java) ?: return null + val croppedRect = result.data?.getParcelable(Extras.KEY_CROPPED_RECT, RectF::class.java) ?: return null + return CroppedImage(croppedUri, croppedRect) + } + + private suspend fun getCropImageResult(result: ActivityResult): CroppedImage? { + return withContext(Dispatchers.IO) { + val outputFile = getFileStreamPath(GIFTICON_IMAGE_CROPPED) + val imageResult = getCropResult(result) + if (imageResult?.uri != null) { + FileInputStream(imageResult.uri.path).use { input -> + FileOutputStream(outputFile).use { output -> + input.copyTo(output) + } + } + CroppedImage(outputFile.toUri(), imageResult.croppedRect) + } else { + null + } + } + } + + private val cropGifticonImage = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + lifecycleScope.launch { + viewModel.updateCroppedGifticonImage(getCropImageResult(result)) + } + } + + private val cropGifticonName = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeGifticonName(getCropResult(result)) + } + + private val cropBrandName = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeBrand(getCropResult(result)) + } + + private val cropBarcode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeBarcode(getCropResult(result)) + } + + private val cropBalance = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeBalance(getCropResult(result)) + } + + private val cropExpired = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + viewModel.recognizeGifticonExpired(getCropResult(result)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_modify_gifticon) + binding.apply { + lifecycleOwner = this@ModifyGifticonActivity + vm = viewModel + } + + setUpBackPressed() + collectEvent() + } + + private fun setUpBackPressed() { + onBackPressedDispatcher.addCallback { + viewModel.requestPopBackstack() + } + } + + private fun collectEvent() { + repeatOnStarted { + viewModel.eventFlow.collect { event -> + when (event) { + is ModifyGifticonEvent.PopupBackStack -> cancelModifyGifticon() + is ModifyGifticonEvent.ShowCancelConfirmation -> showConfirmationCancelDialog() + is ModifyGifticonEvent.NavigateToCrop -> gotoCrop( + event.crop, + event.originFileName, + event.croppedRect + ) + is ModifyGifticonEvent.ShowOriginGifticon -> showOriginGifticonDialog(event.originFileName) + is ModifyGifticonEvent.ShowExpiredAtDatePicker -> showExpiredAtDatePicker(event.date) + is ModifyGifticonEvent.RequestFocus -> requestFocus(event.tag) + is ModifyGifticonEvent.RequestScroll -> requestScroll(event.tag) + is ModifyGifticonEvent.ShowSnackBar -> showSnackBar(event.uiText) + is ModifyGifticonEvent.ModifyCompleted -> completeModifyGifticon() + } + } + } + } + + private fun cancelModifyGifticon() { + setResult(Activity.RESULT_CANCELED) + finish() + } + + private fun completeModifyGifticon() { + setResult(Activity.RESULT_OK) + finish() + } + + private fun gotoCrop(crop: ModifyGifticonCrop, originFileName: String, croppedRect: RectF) { + val gotoCropLauncher = when (crop) { + ModifyGifticonCrop.GIFTICON_IMAGE -> cropGifticonImage + ModifyGifticonCrop.GIFTICON_NAME -> cropGifticonName + ModifyGifticonCrop.BRAND_NAME -> cropBrandName + ModifyGifticonCrop.BARCODE -> cropBarcode + ModifyGifticonCrop.BALANCE -> cropBalance + ModifyGifticonCrop.EXPIRED -> cropExpired + } + val originUri = getFileStreamPath(originFileName).toUri() + val intent = Intent(this, CropGifticonActivity::class.java).apply { + putExtra(Extras.KEY_ORIGIN_IMAGE, originUri) + putExtra(Extras.KEY_CROPPED_RECT, croppedRect) + putExtra(Extras.KEY_ENABLE_ASPECT_RATIO, crop == ModifyGifticonCrop.GIFTICON_IMAGE) + } + gotoCropLauncher.launch(intent) + } + + private var originImageDialog: OriginImageDialog? = null + + private fun showOriginGifticonDialog(originFileName: String) { + if (originImageDialog?.isAdded == true) { + originImageDialog?.dismiss() + } + val originUri = getFileStreamPath(originFileName).toUri() + originImageDialog = OriginImageDialog().apply { + arguments = Bundle().apply { + putParcelable(Extras.KEY_ORIGIN_IMAGE, originUri) + } + } + originImageDialog?.show(supportFragmentManager) + } + + private var spinnerDatePicker: SpinnerDatePicker? = null + + private fun showExpiredAtDatePicker(date: Date) { + if (spinnerDatePicker?.isAdded == true) { + spinnerDatePicker?.dismiss() + } + spinnerDatePicker = SpinnerDatePicker().apply { + setDate(date) + setOnDatePickListener { year, month, dayOfMonth -> + val newDate = Calendar.getInstance(Locale.getDefault()).let { + it.set(year, month - 1, dayOfMonth) + it.time + } + viewModel.updateExpiredAt(newDate) + } + } + spinnerDatePicker?.show(supportFragmentManager) + } + + private var confirmationCancelDialog: ConfirmationDialog? = null + + private fun showConfirmationCancelDialog() { + if (confirmationCancelDialog?.isAdded == true) { + confirmationCancelDialog?.dismiss() + } + val title = getString(R.string.modify_gifticon_confirmation_cancel_title) + val message = getString(R.string.modify_gifticon_confirmation_cancel_message) + confirmationCancelDialog = ConfirmationDialog().apply { + setTitle(title) + setMessage(message) + setOnOkClickListener { + cancelModifyGifticon() + } + } + confirmationCancelDialog?.show(supportFragmentManager) + } + + private fun requestFocus(tag: ModifyGifticonTag) { + val focusView = when (tag) { + ModifyGifticonTag.GIFTICON_NAME -> binding.tietName + ModifyGifticonTag.BRAND_NAME -> binding.tietBrand + ModifyGifticonTag.BARCODE -> binding.tietBarcode + ModifyGifticonTag.BALANCE -> binding.tietBalance + else -> binding.clContainer + } + focusView.requestFocus() + val inputMethodService = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + if (tag.needKeyboard) { + inputMethodService.showSoftInput(focusView, 0) + } else { + inputMethodService.hideSoftInputFromWindow(currentFocus?.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) + } + } + + private fun requestScroll(tag: ModifyGifticonTag) { + val focusView = when (tag) { + ModifyGifticonTag.GIFTICON_NAME -> binding.tvName + ModifyGifticonTag.BRAND_NAME -> binding.tvBrand + ModifyGifticonTag.APPROVE_BRAND_NAME -> binding.tvApproveBrandNameDescription + ModifyGifticonTag.BARCODE -> binding.tvBarcode + ModifyGifticonTag.BALANCE -> binding.tvBalance + else -> null + } ?: return + + val dir = if (binding.nsv.scrollY - focusView.top > 0) SCROLL_DIR_DOWN else SCROLL_DIR_UP + if (binding.nsv.canScrollVertically(dir)) { + binding.nsv.smoothScrollTo(0, focusView.top) + } + } + + private fun showSnackBar(uiText: UIText) { + Snackbar.make(binding.root, uiText.asString(applicationContext), Snackbar.LENGTH_SHORT).show() + } + + companion object { + private const val SCROLL_DIR_UP = 1 + private const val SCROLL_DIR_DOWN = -1 + + private const val GIFTICON_IMAGE_CROPPED = "gifticon_image_cropped.jpg" + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/ModifyGifticonViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/ModifyGifticonViewModel.kt new file mode 100644 index 000000000..c8dd0623d --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/ModifyGifticonViewModel.kt @@ -0,0 +1,614 @@ +package com.lighthouse.presentation.ui.edit.modifygifticon + +import android.graphics.RectF +import android.view.View +import android.view.inputmethod.EditorInfo +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.usecase.edit.HasGifticonBrandUseCase +import com.lighthouse.domain.usecase.edit.modifygifticon.GetGifticonForUpdateUseCase +import com.lighthouse.domain.usecase.edit.modifygifticon.ModifyGifticonUseCase +import com.lighthouse.domain.usecase.edit.modifygifticon.ModifyRecognizeUseCase +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.toDayOfMonth +import com.lighthouse.presentation.extension.toDigit +import com.lighthouse.presentation.extension.toMonth +import com.lighthouse.presentation.extension.toYear +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.mapper.toDomain +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.model.CroppedImage +import com.lighthouse.presentation.model.ModifyGifticonUIModel +import com.lighthouse.presentation.ui.edit.modifygifticon.event.ModifyGifticonCrop +import com.lighthouse.presentation.ui.edit.modifygifticon.event.ModifyGifticonEvent +import com.lighthouse.presentation.ui.edit.modifygifticon.event.ModifyGifticonTag +import com.lighthouse.presentation.ui.edit.modifygifticon.event.ModifyGifticonValid +import com.lighthouse.presentation.util.flow.MutableEventFlow +import com.lighthouse.presentation.util.flow.asEventFlow +import com.lighthouse.presentation.util.resource.AnimInfo +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class ModifyGifticonViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + getGifticonForUpdateUseCase: GetGifticonForUpdateUseCase, + private val hasGifticonBrandUseCase: HasGifticonBrandUseCase, + private val modifyGifticonUseCase: ModifyGifticonUseCase, + private val modifyRecognizeUseCase: ModifyRecognizeUseCase +) : ViewModel() { + + private val today = Calendar.getInstance().let { + it.set(Calendar.HOUR, 0) + it.set(Calendar.MINUTE, 0) + it.set(Calendar.SECOND, 0) + it.time + } + + private val _eventFlow = MutableEventFlow() + val eventFlow = _eventFlow.asEventFlow() + + private val gifticonId = savedStateHandle.get(Extras.KEY_MODIFY_GIFTICON_ID) ?: "" + private var originGifticon: ModifyGifticonUIModel? = null + private var gifticon = MutableStateFlow(null) + + private fun isNothingChanged(): Boolean { + return false + } + + private fun updateGifticon( + update: (ModifyGifticonUIModel) -> ModifyGifticonUIModel + ) { + gifticon.value = gifticon.value?.let { + update(it) + } + } + + val displayGifticonImage = gifticon.map { + it?.croppedUri + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun updateCroppedGifticonImage(croppedImage: CroppedImage?) { + croppedImage?.uri ?: return + updateGifticon { it.copy(croppedUri = croppedImage.uri, croppedRect = croppedImage.croppedRect) } + } + + val isCashCard = gifticon.map { + it?.isCashCard ?: false + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun updateCashCard(checked: Boolean) { + updateGifticon { it.copy(isCashCard = checked) } + } + + val displayName = MutableStateFlow("") + + private val name = gifticon.map { + it?.name ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + fun updateGifticonName(name: CharSequence) { + updateGifticon { it.copy(name = name.toString()) } + } + + private val nameFocus = MutableStateFlow(false) + + fun onNameFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(ModifyGifticonTag.GIFTICON_NAME) + } + nameFocus.value = hasFocus + } + + val nameRemoveVisible = name.combine(nameFocus) { name, focus -> + name.isNotEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeName() { + updateGifticon { it.copy(name = "") } + } + + val displayBrand = MutableStateFlow("") + + private val brand = gifticon.map { + it?.brandName ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private val confirmedBrandMap = hashMapOf() + + private val approveBrandName = gifticon.map { + it?.approveBrandName ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + private var hasGifticonBrandJob: Job? = null + + val isLoadingConfirmBrand = MutableStateFlow(false) + + private fun checkHasGifticonBrand(brand: String) { + hasGifticonBrandJob?.cancel() + if (brand.isNotEmpty() && brand != approveBrandName.value) { + hasGifticonBrandJob = viewModelScope.launch { + isLoadingConfirmBrand.value = true + delay(1000) + val approve = confirmedBrandMap[brand] ?: run { + hasGifticonBrandUseCase(brand).also { + confirmedBrandMap[brand] = it + } + } + updateGifticon { it.copy(approveBrandName = if (approve) brand else "") } + } + hasGifticonBrandJob?.invokeOnCompletion { + isLoadingConfirmBrand.value = false + } + } + } + + fun updateBrandName(brandName: CharSequence) { + val brand = brandName.toString() + updateGifticon { it.copy(brandName = brand) } + checkHasGifticonBrand(brand) + } + + private val brandFocus = MutableStateFlow(false) + + fun onBrandFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(ModifyGifticonTag.BRAND_NAME) + } + brandFocus.value = hasFocus + } + + val brandRemoveVisible = brand.combine(brandFocus) { brand, focus -> + brand.isNotEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeBrand() { + updateBrandName("") + } + + private val isApproveBrandNameVisible = brand.combine(isLoadingConfirmBrand) { brand, isLoading -> + brand != "" && !isLoading + } + + val isApproveBrandNameVisibility = isApproveBrandNameVisible.map { isVisible -> + if (isVisible) View.VISIBLE else View.INVISIBLE + }.stateIn(viewModelScope, SharingStarted.Eagerly, View.INVISIBLE) + + private val isApproveBrandName = brand.combine(approveBrandName) { brand, approveBrand -> + brand == approveBrand + } + + val isApproveBrandNameDescriptionVisible = + isApproveBrandName.combine(isApproveBrandNameVisible) { isApprove, isVisible -> + isApprove.not() && isVisible + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isApproveBrandNameResId = isApproveBrandName.map { isApprove -> + if (isApprove) R.drawable.ic_confirm else R.drawable.ic_question + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveBrandNameTint = isApproveBrandName.map { isApprove -> + if (isApprove) R.color.point_green else R.color.yellow + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveBrandNameAnimation = isApproveBrandName.combine(isApproveBrandNameVisible) { isApprove, isVisible -> + if (isApprove) { + AnimInfo.AnimResource(R.anim.anim_fadein_up, isVisible) + } else { + AnimInfo.AnimResource(R.anim.anim_jump, isVisible) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, AnimInfo.Empty) + + fun approveBrandName() { + val approveBrandName = brand.value + confirmedBrandMap[approveBrandName] = true + updateGifticon { it.copy(approveBrandName = approveBrandName) } + } + + val barcode = gifticon.map { + it?.barcode ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + fun updateBarcode(barcode: String) { + updateGifticon { it.copy(barcode = barcode) } + } + + private val barcodeFocus = MutableStateFlow(false) + + fun onBarcodeFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(ModifyGifticonTag.BARCODE) + } + barcodeFocus.value = hasFocus + } + + val barcodeRemoveVisible = barcode.combine(barcodeFocus) { barcode, focus -> + barcode.isNotEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeBarcode() { + updateBarcode("") + } + + val balance = gifticon.map { + it?.balance ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + fun updateBalance(balance: String) { + updateGifticon { it.copy(balance = balance) } + } + + private val balanceFocus = MutableStateFlow(false) + + fun onBalanceFocusChangeListener(hasFocus: Boolean) { + if (hasFocus) { + requestScroll(ModifyGifticonTag.BALANCE) + } + balanceFocus.value = hasFocus + } + + val balanceRemoveVisible = balance.combine(balanceFocus) { balance, focus -> + balance != "0" && balance.isNotEmpty() && focus + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun removeBalance() { + updateBalance("") + } + + private val expiredAt = gifticon.map { + it?.expiredAt ?: EMPTY_DATE + }.stateIn(viewModelScope, SharingStarted.Eagerly, EMPTY_DATE) + + private val expiredAtDate: Date? + get() = expiredAt.value.let { + if (it == EMPTY_DATE) today else it + } + + val expiredAtUIText = expiredAt.map { date -> + if (date != EMPTY_DATE) { + UIText.StringResource(R.string.all_date, date.toYear(), date.toMonth(), date.toDayOfMonth()) + } else { + UIText.Empty + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, UIText.Empty) + + private val approveExpiredAt = gifticon.map { + it?.approveExpiredAt ?: false + } + + fun updateExpiredAt(expiredAt: Date) { + updateGifticon { it.copy(expiredAt = expiredAt) } + } + + private val isApproveExpired = expiredAt.combine(approveExpiredAt) { expiredAt, approve -> + expiredAt >= today || approve + } + + val isApproveExpiredAtDescriptionVisible = expiredAt.combine(approveExpiredAt) { expiredAt, approve -> + expiredAt != EMPTY_DATE && (expiredAt < today && approve.not()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isApproveExpiredAtVisible = expiredAt.map { expiredAt -> + expiredAt != EMPTY_DATE + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val isApproveExpiredAtResId = isApproveExpired.map { isApprove -> + if (isApprove) R.drawable.ic_confirm else R.drawable.ic_question + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveExpiredAtTint = isApproveExpired.map { isApprove -> + if (isApprove) R.color.point_green else R.color.yellow + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + val isApproveExpiredAtAnimation = isApproveExpired.combine(isApproveExpiredAtVisible) { isApprove, isVisible -> + if (isApprove) { + AnimInfo.AnimResource(R.anim.anim_fadein_up, isVisible) + } else { + AnimInfo.AnimResource(R.anim.anim_jump, isVisible) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, AnimInfo.Empty) + + fun approveExpiredAt() { + updateGifticon { it.copy(approveExpiredAt = true) } + } + + val memo = gifticon.map { + it?.memo ?: "" + }.stateIn(viewModelScope, SharingStarted.Eagerly, "") + + fun updateMemo(memo: CharSequence) { + updateGifticon { it.copy(memo = memo.toString()) } + } + + init { + viewModelScope.launch { + val modifyGifticon = getGifticonForUpdateUseCase(gifticonId)?.toPresentation() ?: return@launch + loadGifticon(modifyGifticon) + } + } + + private fun loadGifticon(modifyGifticon: ModifyGifticonUIModel) { + originGifticon = modifyGifticon + gifticon.value = modifyGifticon + displayName.value = modifyGifticon.name + displayBrand.value = modifyGifticon.brandName + confirmedBrandMap[modifyGifticon.brandName] = true + } + + fun onActionNextListener(actionId: Int): Boolean { + val gifticon = gifticon.value ?: return false + if (actionId == EditorInfo.IME_ACTION_NEXT) { + val event = when (checkGifticonValid(gifticon)) { + ModifyGifticonValid.INVALID_GIFTICON_NAME -> ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.GIFTICON_NAME) + ModifyGifticonValid.INVALID_BRAND_NAME -> ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.BRAND_NAME) + ModifyGifticonValid.INVALID_BARCODE -> ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.BARCODE) + ModifyGifticonValid.INVALID_BALANCE -> ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.BALANCE) + ModifyGifticonValid.INVALID_EXPIRED_AT -> ModifyGifticonEvent.ShowExpiredAtDatePicker( + expiredAtDate ?: today + ) + else -> { + requestModifyGifticon() + return true + } + } + viewModelScope.launch { + _eventFlow.emit(event) + } + return true + } + return false + } + + fun recognizeGifticonName(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = modifyRecognizeUseCase.gifticonName(uri.toString()) + if (result != "") { + updateGifticon { it.copy(name = result, nameRectF = croppedImage.croppedRect) } + displayName.value = result + } else { + updateGifticon { it.copy(nameRectF = croppedImage.croppedRect) } + _eventFlow.emit(ModifyGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_name))) + } + } + } + + fun recognizeBrand(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = modifyRecognizeUseCase.brandName(uri.toString()) + if (result != "") { + updateGifticon { it.copy(brandName = result, brandNameRectF = croppedImage.croppedRect) } + displayBrand.value = result + } else { + updateGifticon { it.copy(brandNameRectF = croppedImage.croppedRect) } + _eventFlow.emit(ModifyGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_brand))) + } + } + } + + fun recognizeBarcode(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = modifyRecognizeUseCase.barcode(uri.toString()) + if (result != "") { + updateGifticon { it.copy(barcode = result, barcodeRectF = croppedImage.croppedRect) } + } else { + updateGifticon { it.copy(barcodeRectF = croppedImage.croppedRect) } + _eventFlow.emit(ModifyGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_barcode))) + } + } + } + + fun recognizeBalance(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = modifyRecognizeUseCase.balance(uri.toString()) + if (result > 0) { + updateGifticon { + it.copy( + isCashCard = true, + balance = result.toString(), + balanceRectF = croppedImage.croppedRect + ) + } + } else { + updateGifticon { it.copy(balanceRectF = croppedImage.croppedRect) } + _eventFlow.emit(ModifyGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_balance))) + } + } + } + + fun recognizeGifticonExpired(croppedImage: CroppedImage?) { + croppedImage ?: return + val uri = croppedImage.uri ?: return + viewModelScope.launch { + val result = modifyRecognizeUseCase.expired(uri.toString()) + if (result != EMPTY_DATE) { + updateGifticon { it.copy(expiredAt = result, expiredAtRectF = croppedImage.croppedRect) } + } else { + updateGifticon { it.copy(expiredAtRectF = croppedImage.croppedRect) } + _eventFlow.emit(ModifyGifticonEvent.ShowSnackBar(UIText.StringResource(R.string.edit_gifticon_failed_recognize_expired_at))) + } + } + } + + private fun checkGifticonValid(gifticon: ModifyGifticonUIModel): ModifyGifticonValid { + return when { + isNothingChanged() -> ModifyGifticonValid.INVALID_NOTHING_CHANGED + gifticon.name.isEmpty() -> ModifyGifticonValid.INVALID_GIFTICON_NAME + gifticon.brandName.isEmpty() -> ModifyGifticonValid.INVALID_BRAND_NAME + gifticon.brandName != gifticon.approveBrandName -> ModifyGifticonValid.INVALID_APPROVE_BRAND_NAME + gifticon.barcode.length !in VALID_BARCODE_COUNT -> ModifyGifticonValid.INVALID_BARCODE + gifticon.isCashCard && gifticon.balance.toDigit() == 0 -> ModifyGifticonValid.INVALID_BALANCE + gifticon.expiredAt == EMPTY_DATE -> ModifyGifticonValid.INVALID_EXPIRED_AT + gifticon.expiredAt < today && gifticon.approveExpiredAt.not() -> ModifyGifticonValid.INVALID_APPROVE_EXPIRED_AT + else -> ModifyGifticonValid.VALID + } + } + + private fun handleGifticonInvalid(valid: ModifyGifticonValid) { + viewModelScope.launch { + when (valid) { + ModifyGifticonValid.VALID -> {} + else -> { + _eventFlow.emit(ModifyGifticonEvent.ShowSnackBar(valid.text)) + _eventFlow.emit(ModifyGifticonEvent.RequestFocus(valid.tag)) + _eventFlow.emit(ModifyGifticonEvent.RequestScroll(valid.tag)) + } + } + } + } + + fun requestCashCard() { + val gifticon = gifticon.value ?: return + viewModelScope.launch { + val event = if (gifticon.isCashCard) { + ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.BALANCE) + } else { + ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.NONE) + } + _eventFlow.emit(event) + } + } + + fun requestPopBackstack() { + if (isNothingChanged()) { + popBackstack() + } else { + showCancelConfirmation() + } + } + + private fun popBackstack() { + viewModelScope.launch { + _eventFlow.emit(ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.NONE)) + _eventFlow.emit(ModifyGifticonEvent.PopupBackStack) + } + } + + private fun showCancelConfirmation() { + viewModelScope.launch { + _eventFlow.emit(ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.NONE)) + _eventFlow.emit(ModifyGifticonEvent.ShowCancelConfirmation) + } + } + + private fun gotoCropGifticon( + crop: ModifyGifticonCrop, + croppedRect: RectF + ) { + val originFileName = gifticon.value?.originFileName ?: return + viewModelScope.launch { + _eventFlow.emit(ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.NONE)) + _eventFlow.emit( + ModifyGifticonEvent.NavigateToCrop(crop, originFileName, croppedRect) + ) + } + } + + fun gotoCropGifticonImage() { + val gifticon = gifticon.value ?: return + gotoCropGifticon( + crop = ModifyGifticonCrop.GIFTICON_IMAGE, + croppedRect = gifticon.croppedRect + ) + } + + fun gotoCropGifticonName() { + val gifticon = gifticon.value ?: return + gotoCropGifticon( + crop = ModifyGifticonCrop.GIFTICON_NAME, + croppedRect = gifticon.nameRectF + ) + } + + fun gotoCropBrandName() { + val gifticon = gifticon.value ?: return + gotoCropGifticon( + crop = ModifyGifticonCrop.BRAND_NAME, + croppedRect = gifticon.brandNameRectF + ) + } + + fun gotoCropBarcode() { + val gifticon = gifticon.value ?: return + gotoCropGifticon( + crop = ModifyGifticonCrop.BARCODE, + croppedRect = gifticon.barcodeRectF + ) + } + + fun gotoCropBalance() { + val gifticon = gifticon.value ?: return + gotoCropGifticon( + crop = ModifyGifticonCrop.BALANCE, + croppedRect = gifticon.balanceRectF + ) + } + + fun gotoCropExpired() { + val gifticon = gifticon.value ?: return + gotoCropGifticon( + crop = ModifyGifticonCrop.EXPIRED, + croppedRect = gifticon.expiredAtRectF + ) + } + + fun showOriginGifticon() { + val originFileName = gifticon.value?.originFileName ?: return + viewModelScope.launch { + _eventFlow.emit(ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.NONE)) + _eventFlow.emit(ModifyGifticonEvent.ShowOriginGifticon(originFileName)) + } + } + + fun showExpiredAtDatePicker() { + val expiredAt = expiredAtDate ?: return + viewModelScope.launch { + _eventFlow.emit(ModifyGifticonEvent.RequestFocus(ModifyGifticonTag.NONE)) + _eventFlow.emit(ModifyGifticonEvent.ShowExpiredAtDatePicker(expiredAt)) + } + } + + private fun requestScroll(tag: ModifyGifticonTag) { + viewModelScope.launch { + _eventFlow.emit(ModifyGifticonEvent.RequestScroll(tag)) + } + } + + fun requestModifyGifticon() { + val gifticon = gifticon.value ?: return + viewModelScope.launch { + val valid: ModifyGifticonValid = checkGifticonValid(gifticon) + if (valid == ModifyGifticonValid.VALID) { + modifyGifticonUseCase(gifticon.toDomain()) + _eventFlow.emit(ModifyGifticonEvent.ModifyCompleted) + } else { + handleGifticonInvalid(valid) + } + } + } + + companion object { + private val EMPTY_DATE = Date(0) + + private val VALID_BARCODE_COUNT = setOf(12, 14, 16, 18, 20, 22, 24) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonCrop.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonCrop.kt new file mode 100644 index 000000000..b6fec4b35 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonCrop.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.ui.edit.modifygifticon.event + +enum class ModifyGifticonCrop { + GIFTICON_IMAGE, + GIFTICON_NAME, + BRAND_NAME, + BARCODE, + BALANCE, + EXPIRED +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonEvent.kt new file mode 100644 index 000000000..b2ae3d24d --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonEvent.kt @@ -0,0 +1,20 @@ +package com.lighthouse.presentation.ui.edit.modifygifticon.event + +import android.graphics.RectF +import com.lighthouse.presentation.util.resource.UIText +import java.util.Date + +sealed class ModifyGifticonEvent { + + object PopupBackStack : ModifyGifticonEvent() + object ShowCancelConfirmation : ModifyGifticonEvent() + object ModifyCompleted : ModifyGifticonEvent() + data class NavigateToCrop(val crop: ModifyGifticonCrop, val originFileName: String, val croppedRect: RectF) : + ModifyGifticonEvent() + + data class ShowOriginGifticon(val originFileName: String) : ModifyGifticonEvent() + data class ShowExpiredAtDatePicker(val date: Date) : ModifyGifticonEvent() + data class RequestFocus(val tag: ModifyGifticonTag) : ModifyGifticonEvent() + data class RequestScroll(val tag: ModifyGifticonTag) : ModifyGifticonEvent() + data class ShowSnackBar(val uiText: UIText) : ModifyGifticonEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonTag.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonTag.kt new file mode 100644 index 000000000..d5e3a8620 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonTag.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.ui.edit.modifygifticon.event + +enum class ModifyGifticonTag(val needKeyboard: Boolean) { + GIFTICON_NAME(true), + BRAND_NAME(true), + APPROVE_BRAND_NAME(false), + BARCODE(true), + BALANCE(true), + NONE(false) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonValid.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonValid.kt new file mode 100644 index 000000000..301433f93 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/edit/modifygifticon/event/ModifyGifticonValid.kt @@ -0,0 +1,40 @@ +package com.lighthouse.presentation.ui.edit.modifygifticon.event + +import com.lighthouse.presentation.R +import com.lighthouse.presentation.util.resource.UIText + +enum class ModifyGifticonValid(val tag: ModifyGifticonTag, val text: UIText) { + INVALID_NOTHING_CHANGED( + ModifyGifticonTag.NONE, + UIText.StringResource(R.string.modify_gifticon_invalid_nothing_changed) + ), + INVALID_GIFTICON_NAME( + ModifyGifticonTag.GIFTICON_NAME, + UIText.StringResource(R.string.edit_gifticon_invalid_gifticon_name) + ), + INVALID_BRAND_NAME( + ModifyGifticonTag.BRAND_NAME, + UIText.StringResource(R.string.edit_gifticon_invalid_brand_name) + ), + INVALID_APPROVE_BRAND_NAME( + ModifyGifticonTag.APPROVE_BRAND_NAME, + UIText.StringResource(R.string.edit_gifticon_invalid_approve_brand_name) + ), + INVALID_BARCODE( + ModifyGifticonTag.BARCODE, + UIText.StringResource(R.string.edit_gifticon_invalid_barcode) + ), + INVALID_EXPIRED_AT( + ModifyGifticonTag.NONE, + UIText.StringResource(R.string.edit_gifticon_invalid_expired_at) + ), + INVALID_APPROVE_EXPIRED_AT( + ModifyGifticonTag.NONE, + UIText.StringResource(R.string.edit_gifticon_invalid_approve_expired_at) + ), + INVALID_BALANCE( + ModifyGifticonTag.BALANCE, + UIText.StringResource(R.string.edit_gifticon_invalid_balance) + ), + VALID(ModifyGifticonTag.NONE, UIText.Empty) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/GalleryActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/GalleryActivity.kt new file mode 100644 index 000000000..c83ad5f6f --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/GalleryActivity.kt @@ -0,0 +1,116 @@ +package com.lighthouse.presentation.ui.gallery + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.activity.addCallback +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.recyclerview.widget.GridLayoutManager +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivityGalleryBinding +import com.lighthouse.presentation.extension.dp +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.ui.gallery.adapter.list.GalleryAdapter +import com.lighthouse.presentation.ui.gallery.adapter.selected.SelectedGalleryAdapter +import com.lighthouse.presentation.ui.gallery.event.GalleryEvent +import com.lighthouse.presentation.util.recycler.GridSectionSpaceItemDecoration +import com.lighthouse.presentation.util.recycler.ListSpaceItemDecoration +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class GalleryActivity : AppCompatActivity() { + + private lateinit var binding: ActivityGalleryBinding + + private val viewModel: GalleryViewModel by viewModels() + + private val selectedGalleryAdapter by lazy { + SelectedGalleryAdapter(onClickGallery = { + viewModel.removeItem(it) + binding.abl.requestLayout() + }) + } + + private val galleryAdapter by lazy { + GalleryAdapter(onClickGallery = { + viewModel.selectItem(it) + binding.abl.requestLayout() + }) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = DataBindingUtil.setContentView(this, R.layout.activity_gallery) + binding.apply { + lifecycleOwner = this@GalleryActivity + vm = viewModel + } + + setUpOnBackPressed() + setUpSelectedGalleryList() + setUpGalleryList() + collectEvent() + } + + private fun setUpOnBackPressed() { + onBackPressedDispatcher.addCallback { + cancelPhotoSelection() + } + } + + private fun setUpSelectedGalleryList() { + binding.rvSelectedList.apply { + adapter = selectedGalleryAdapter + addItemDecoration(ListSpaceItemDecoration(4.dp, 32.dp, 4.dp, 32.dp, 4.dp)) + } + } + + private fun setUpGalleryList() { + val spanCount = 3 + binding.rvList.apply { + adapter = galleryAdapter + layoutManager = GridLayoutManager(context, spanCount).apply { + spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return if (galleryAdapter.getItemViewType(position) == GalleryAdapter.TYPE_HEADER) spanCount else 1 + } + } + } + addItemDecoration(GridSectionSpaceItemDecoration(20.dp, 4.dp, 12.dp, 12.dp)) + } + } + + private fun collectEvent() { + repeatOnStarted { + viewModel.eventsFlow.collect { events -> + when (events) { + is GalleryEvent.CompleteSelect -> completePhotoSelection() + is GalleryEvent.PopupBackStack -> cancelPhotoSelection() + } + } + } + } + + private fun completePhotoSelection() { + val intent = Intent().apply { + putParcelableArrayListExtra( + Extras.KEY_SELECTED_GALLERY_ITEM, + ArrayList().apply { + addAll(viewModel.selectedList.value) + } + ) + } + setResult(Activity.RESULT_OK, intent) + finish() + } + + private fun cancelPhotoSelection() { + setResult(Activity.RESULT_CANCELED) + finish() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/GalleryViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/GalleryViewModel.kt new file mode 100644 index 000000000..956d48a94 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/GalleryViewModel.kt @@ -0,0 +1,111 @@ +package com.lighthouse.presentation.ui.gallery + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.insertSeparators +import androidx.paging.map +import com.lighthouse.domain.usecase.gallery.GetGalleryImagesUseCase +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.ui.gallery.event.GalleryEvent +import com.lighthouse.presentation.util.flow.MutableEventFlow +import com.lighthouse.presentation.util.flow.asEventFlow +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class GalleryViewModel @Inject constructor( + getGalleryImagesUseCase: GetGalleryImagesUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _eventsFlow = MutableEventFlow() + val eventsFlow = _eventsFlow.asEventFlow() + + private val _pagingData = getGalleryImagesUseCase().cachedIn(viewModelScope) + + private val _selectedList = MutableStateFlow>( + savedStateHandle[Extras.KEY_SELECTED_GALLERY_ITEM] ?: emptyList() + ) + val selectedList = _selectedList.asStateFlow() + + val list = _pagingData.combine(_selectedList) { pagingData, selectedList -> + pagingData.map { galleryImage -> + galleryImage.toPresentation( + selectedList.indexOfFirst { gallery -> galleryImage.id == gallery.id } + ) + }.insertSeparators { before: GalleryUIModel.Gallery?, after: GalleryUIModel.Gallery? -> + if (before == null && after != null) { + GalleryUIModel.Header(after.createdDate) + } else if (before != null && after != null) { + if (before.createdDate != after.createdDate) { + GalleryUIModel.Header(after.createdDate) + } else { + null + } + } else { + null + } + } + }.cachedIn(viewModelScope) + .stateIn(viewModelScope, SharingStarted.Eagerly, PagingData.empty()) + + val isSelected = _selectedList.map { list -> + list.isNotEmpty() + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val titleText = _selectedList.map { list -> + if (list.isNotEmpty()) { + UIText.StringResource(R.string.gallery_selected, list.size) + } else { + UIText.StringResource(R.string.gallery_title) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, UIText.Empty) + + fun selectItem(gallery: GalleryUIModel.Gallery) { + val oldList = _selectedList.value + val index = oldList.indexOfFirst { item -> + item.id == gallery.id + } + _selectedList.value = if (index == -1) { + oldList + listOf(gallery) + } else { + oldList.subList(0, index) + oldList.subList(index + 1, oldList.size) + } + } + + fun removeItem(gallery: GalleryUIModel.Gallery) { + val oldList = _selectedList.value + val index = oldList.indexOfFirst { item -> + item.id == gallery.id + } + if (index != -1) { + _selectedList.value = oldList.subList(0, index) + oldList.subList(index + 1, oldList.size) + } + } + + fun cancelPhotoSelection() { + viewModelScope.launch { + _eventsFlow.emit(GalleryEvent.PopupBackStack) + } + } + + fun completePhotoSelection() { + viewModelScope.launch { + _eventsFlow.emit(GalleryEvent.CompleteSelect) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/GallerySelection.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/GallerySelection.kt new file mode 100644 index 000000000..1298741fb --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/GallerySelection.kt @@ -0,0 +1,26 @@ +package com.lighthouse.presentation.ui.gallery.adapter + +import com.lighthouse.presentation.model.GalleryUIModel + +class GallerySelection( + private var selected: List = listOf() +) { + val size + get() = selected.size + + fun toArrayList(): ArrayList = ArrayList(selected) + + fun isSelected(model: GalleryUIModel.Gallery): Boolean { + return model in selected + } + + fun toggle(model: GalleryUIModel.Gallery) { + selected = if (model in selected) { + selected.filter { + it != model + } + } else { + selected + listOf(model) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryAdapter.kt new file mode 100644 index 000000000..e63c135c5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryAdapter.kt @@ -0,0 +1,86 @@ +package com.lighthouse.presentation.ui.gallery.adapter.list + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.adapter.BindablePagingAdapter +import com.lighthouse.presentation.model.GalleryUIModel + +class GalleryAdapter( + private val onClickGallery: (GalleryUIModel.Gallery) -> Unit +) : BindablePagingAdapter(diff) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (viewType) { + TYPE_HEADER -> GalleryHeaderViewHolder(parent) + TYPE_GALLERY -> GalleryItemViewHolder(parent, onClickGallery) + else -> throw IllegalArgumentException("잘못된 viewType 입니다.") + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = getItem(position) + when { + holder is GalleryHeaderViewHolder && item is GalleryUIModel.Header -> { + holder.bind(item) + } + holder is GalleryItemViewHolder && item is GalleryUIModel.Gallery -> { + holder.bind(item) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: MutableList) { + if (UPDATE_SELECTED in payloads) { + val item = getItem(position) + if (holder is GalleryItemViewHolder && item is GalleryUIModel.Gallery) { + holder.bindSelected(item) + } + } else { + onBindViewHolder(holder, position) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is GalleryUIModel.Header -> TYPE_HEADER + is GalleryUIModel.Gallery -> TYPE_GALLERY + else -> throw IllegalArgumentException("잘못된 viewType 입니다.") + } + } + + companion object { + private val diff = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GalleryUIModel, newItem: GalleryUIModel): Boolean { + return when { + oldItem is GalleryUIModel.Header && newItem is GalleryUIModel.Header -> { + oldItem.date == newItem.date + } + oldItem is GalleryUIModel.Gallery && newItem is GalleryUIModel.Gallery -> { + oldItem.id == newItem.id + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: GalleryUIModel, newItem: GalleryUIModel): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: GalleryUIModel, newItem: GalleryUIModel): Any? { + if (oldItem is GalleryUIModel.Gallery && + newItem is GalleryUIModel.Gallery && + oldItem.selectedOrder != newItem.selectedOrder + ) { + return UPDATE_SELECTED + } + return null + } + } + + const val TYPE_HEADER = 1 + const val TYPE_GALLERY = 2 + + private const val UPDATE_SELECTED = 1 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryDisplayModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryDisplayModel.kt new file mode 100644 index 000000000..ac03f5b22 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryDisplayModel.kt @@ -0,0 +1,23 @@ +package com.lighthouse.presentation.ui.gallery.adapter.list + +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.util.resource.UIText + +class GalleryDisplayModel( + var item: GalleryUIModel.Gallery, + private val onClick: (GalleryUIModel.Gallery) -> Unit +) { + val isSelected + get() = item.selectedOrder != -1 + + val selectedOrder + get() = if (isSelected) { + UIText.DynamicString("${item.selectedOrder + 1}") + } else { + UIText.Empty + } + + fun onClickItem() { + onClick(item) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryHeaderViewHolder.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryHeaderViewHolder.kt new file mode 100644 index 000000000..25bd0e263 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryHeaderViewHolder.kt @@ -0,0 +1,20 @@ +package com.lighthouse.presentation.ui.gallery.adapter.list + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ItemGalleryHeaderBinding +import com.lighthouse.presentation.model.GalleryUIModel + +class GalleryHeaderViewHolder( + parent: ViewGroup, + private val binding: ItemGalleryHeaderBinding = ItemGalleryHeaderBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_gallery_header, parent, false) + ) +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: GalleryUIModel.Header) { + binding.item = item + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryItemViewHolder.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryItemViewHolder.kt new file mode 100644 index 000000000..67899e203 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/list/GalleryItemViewHolder.kt @@ -0,0 +1,37 @@ +package com.lighthouse.presentation.ui.gallery.adapter.list + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.binding.setUIText +import com.lighthouse.presentation.databinding.ItemGalleryBinding +import com.lighthouse.presentation.model.GalleryUIModel +import com.lighthouse.presentation.util.resource.UIText + +class GalleryItemViewHolder( + parent: ViewGroup, + private val onClick: (GalleryUIModel.Gallery) -> Unit, + private val binding: ItemGalleryBinding = ItemGalleryBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_gallery, parent, false) + ) +) : RecyclerView.ViewHolder(binding.root) { + private var dm: GalleryDisplayModel? = null + + fun bind(item: GalleryUIModel.Gallery) { + dm = GalleryDisplayModel(item, onClick) + binding.dm = dm + } + + fun bindSelected(item: GalleryUIModel.Gallery) { + dm?.item = item + val isSelected = dm?.isSelected ?: false + val selectedOrder = dm?.selectedOrder ?: UIText.Empty + binding.apply { + viewSelected.setUIText(selectedOrder) + viewSelected.isSelected = isSelected + viewShadow.isVisible = isSelected + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryAdapter.kt new file mode 100644 index 000000000..5118439fe --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryAdapter.kt @@ -0,0 +1,31 @@ +package com.lighthouse.presentation.ui.gallery.adapter.selected + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import com.lighthouse.presentation.adapter.BindableListAdapter +import com.lighthouse.presentation.model.GalleryUIModel + +class SelectedGalleryAdapter( + private val onClickGallery: (GalleryUIModel.Gallery) -> Unit +) : BindableListAdapter(diff) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectedGalleryItemViewHolder { + return SelectedGalleryItemViewHolder(parent, onClickGallery) + } + + override fun onBindViewHolder(holder: SelectedGalleryItemViewHolder, position: Int) { + holder.bind(currentList[position]) + } + + companion object { + private val diff = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GalleryUIModel.Gallery, newItem: GalleryUIModel.Gallery): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: GalleryUIModel.Gallery, newItem: GalleryUIModel.Gallery): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryDisplayModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryDisplayModel.kt new file mode 100644 index 000000000..e9b631098 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryDisplayModel.kt @@ -0,0 +1,12 @@ +package com.lighthouse.presentation.ui.gallery.adapter.selected + +import com.lighthouse.presentation.model.GalleryUIModel + +class SelectedGalleryDisplayModel( + val item: GalleryUIModel.Gallery, + private val onClick: (GalleryUIModel.Gallery) -> Unit +) { + fun onClickItem() { + onClick(item) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryItemViewHolder.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryItemViewHolder.kt new file mode 100644 index 000000000..fea23f16a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/adapter/selected/SelectedGalleryItemViewHolder.kt @@ -0,0 +1,21 @@ +package com.lighthouse.presentation.ui.gallery.adapter.selected + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ItemSelectedGalleryBinding +import com.lighthouse.presentation.model.GalleryUIModel + +class SelectedGalleryItemViewHolder( + parent: ViewGroup, + private val onClick: (GalleryUIModel.Gallery) -> Unit, + private val binding: ItemSelectedGalleryBinding = ItemSelectedGalleryBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_selected_gallery, parent, false) + ) +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(item: GalleryUIModel.Gallery) { + binding.dm = SelectedGalleryDisplayModel(item, onClick) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/event/GalleryEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/event/GalleryEvent.kt new file mode 100644 index 000000000..4954cda23 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gallery/event/GalleryEvent.kt @@ -0,0 +1,8 @@ +package com.lighthouse.presentation.ui.gallery.event + +sealed class GalleryEvent { + + object PopupBackStack : GalleryEvent() + + object CompleteSelect : GalleryEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListFragment.kt new file mode 100644 index 000000000..7ad9d0447 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListFragment.kt @@ -0,0 +1,43 @@ +package com.lighthouse.presentation.ui.gifticonlist + +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import com.google.accompanist.appcompattheme.AppCompatTheme +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentGifticonListBinding +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.gifticonlist.component.GifticonAppBar +import com.lighthouse.presentation.ui.gifticonlist.component.GifticonListScreen +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class GifticonListFragment : Fragment(R.layout.fragment_gifticon_list) { + + private val binding: FragmentGifticonListBinding by viewBindings() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.cvContainer.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppCompatTheme { + Scaffold( + topBar = { + GifticonAppBar() + } + ) { contentPadding -> + GifticonListScreen( + modifier = Modifier.padding(contentPadding) + ) + } + } + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListViewModel.kt new file mode 100644 index 000000000..e3f7cae23 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListViewModel.kt @@ -0,0 +1,159 @@ +package com.lighthouse.presentation.ui.gifticonlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.model.Brand +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.domain.usecase.GetAllBrandsUseCase +import com.lighthouse.domain.usecase.GetFilteredGifticonsUseCase +import com.lighthouse.domain.usecase.RemoveGifticonUseCase +import com.lighthouse.domain.usecase.UseGifticonUseCase +import com.lighthouse.domain.util.isExpired +import com.lighthouse.presentation.mapper.toDomain +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.model.GifticonSortBy +import com.lighthouse.presentation.model.GifticonUIModel +import com.lighthouse.presentation.util.flow.combine +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class GifticonListViewModel @Inject constructor( + getAllBrandsUseCase: GetAllBrandsUseCase, + private val getFilteredGifticonsUseCase: GetFilteredGifticonsUseCase, + private val useGifticonUseCase: UseGifticonUseCase, + private val removeGifticonUseCase: RemoveGifticonUseCase +) : ViewModel() { + + private val filter = MutableStateFlow(setOf()) + private val sortBy = MutableStateFlow(GifticonSortBy.DEADLINE) + private val gifticons = MutableStateFlow>>(DbResult.Loading) + private val brands = MutableStateFlow>>(DbResult.Loading) + private val entireBrandsDialogShown = MutableStateFlow(false) + private val showExpiredGifticon = MutableStateFlow(false) + + init { + viewModelScope.launch { + filter.flatMapLatest { + getFilteredGifticonsUseCase(it, sortBy.value.toDomain()).distinctUntilChanged() + }.debounce(50).collect { dbResult -> + gifticons.value = dbResult + } + } + viewModelScope.launch { + sortBy.flatMapLatest { + getFilteredGifticonsUseCase(filter.value, it.toDomain()).distinctUntilChanged() + }.debounce(50).collect { dbResult -> + gifticons.value = dbResult + } + } + viewModelScope.launch { + showExpiredGifticon.flatMapLatest { showExpired -> + getAllBrandsUseCase(showExpired.not()).distinctUntilChanged() + }.debounce(50).collect { dbResult -> + filter.value = emptySet() // 만료된 기프티콘 표시 옵션이 변경되면 필터 초기화 + brands.value = dbResult + } + } + } + + val state = combine( + sortBy, + gifticons, + showExpiredGifticon, + brands, + entireBrandsDialogShown, + filter + ) { sortBy, gifticonResult, showExpired, brandResult, entireBrandsDialogShown, filter -> + when (gifticonResult) { + is DbResult.Success -> { + val brands = when (brandResult) { + is DbResult.Success -> { + brandResult.data + } + else -> emptyList() + } + GifticonListViewState( + sortBy = sortBy, + gifticons = if (showExpired) { + gifticonResult.data.map { it.toPresentation() } + } else { + gifticonResult.data.filterNot { it.expireAt.isExpired() }.map { it.toPresentation() } + }, + showExpiredGifticon = showExpired, + brands = brands, + entireBrandsDialogShown = entireBrandsDialogShown, + selectedFilter = filter, + loading = false + ) + } + is DbResult.Loading -> { + val gifticons = gifticons.value.let { result -> + if (result is DbResult.Success) result.data.map { it.toPresentation() } else emptyList() + } + GifticonListViewState( + sortBy = sortBy, + gifticons = gifticons, + brands = emptyList(), + entireBrandsDialogShown = entireBrandsDialogShown, + selectedFilter = filter, + loading = true + ) + } + else -> { + GifticonListViewState() + } + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, GifticonListViewState(loading = true)) + + fun showEntireBrandsDialog() { + entireBrandsDialogShown.value = true + } + + fun dismissEntireBrandsDialog() { + entireBrandsDialogShown.value = false + } + + fun toggleFilterSelection(brand: Brand) { + filter.value = if (brand.name in state.value.selectedFilter) { + filter.value.minus(brand.name) + } else { + filter.value.plus(brand.name) + } + } + + fun filterUsedGifticon(show: Boolean = true) { + showExpiredGifticon.value = show + } + + fun clearFilter() { + filter.value = emptySet() + } + + fun completeUsage(gifticon: GifticonUIModel) { + viewModelScope.launch { + useGifticonUseCase(gifticon.id, false) + } + } + + fun removeGifticon(gifticon: GifticonUIModel) { + viewModelScope.launch { + removeGifticonUseCase(gifticon.id) + } + } + + fun sort(newSortBy: GifticonSortBy) { + sortBy.value = newSortBy + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListViewState.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListViewState.kt new file mode 100644 index 000000000..dd517ff40 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/GifticonListViewState.kt @@ -0,0 +1,16 @@ +package com.lighthouse.presentation.ui.gifticonlist + +import com.lighthouse.domain.model.Brand +import com.lighthouse.presentation.model.GifticonSortBy +import com.lighthouse.presentation.model.GifticonUIModel + +data class GifticonListViewState( + val sortBy: GifticonSortBy = GifticonSortBy.DEADLINE, + val gifticons: List = emptyList(), + val showExpiredGifticon: Boolean = false, + val loading: Boolean = false, + val brands: List = emptyList(), + val selectedFilter: Set = emptySet(), + val entireBrandsDialogShown: Boolean = false, + val errorMessage: String? = null +) diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/BrandChip.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/BrandChip.kt new file mode 100644 index 000000000..b0ea56bc5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/BrandChip.kt @@ -0,0 +1,286 @@ +package com.lighthouse.presentation.ui.gifticonlist.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ChipDefaults +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FilterChip +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.accompanist.flowlayout.FlowMainAxisAlignment +import com.google.accompanist.flowlayout.FlowRow +import com.google.accompanist.flowlayout.SizeMode +import com.google.accompanist.placeholder.PlaceholderHighlight +import com.google.accompanist.placeholder.material.placeholder +import com.google.accompanist.placeholder.material.shimmer +import com.lighthouse.domain.model.Brand +import com.lighthouse.presentation.R +import com.lighthouse.presentation.ui.common.compose.TextCheckbox + +@Composable +fun BrandChipListScreen( + modifier: Modifier, + brands: List, + filters: Set, + onClickEntireBrandDialog: () -> Unit = {}, + onClickTotalChip: () -> Unit = {}, + onClickChip: (Brand) -> Unit = {} +) { + Row( + modifier = modifier + ) { + BrandChipList( + modifier = Modifier.weight(1f), + brands = brands, + selectedFilters = filters, + onClickTotalChip = { + onClickTotalChip() + }, + onClickChip = { + onClickChip(it) + } + ) + IconButton( + modifier = Modifier, + onClick = { + onClickEntireBrandDialog() + } + ) { + Image( + imageVector = Icons.Outlined.Tune, + colorFilter = ColorFilter.tint(MaterialTheme.colors.onSurface), + contentDescription = stringResource(R.string.gifticon_list_show_all_brand_chips_button) + ) + } + } +} + +@Composable +fun BrandChipList( + modifier: Modifier = Modifier, + brands: List = emptyList(), + selectedFilters: Set = emptySet(), + onClickTotalChip: () -> Unit = {}, + onClickChip: (Brand) -> Unit = {} +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + item { // "전체" 칩 + val entireChipBrand = Brand( + name = stringResource(id = R.string.main_filter_all), + count = brands.sumOf { it.count } + ) + BrandChip( + brand = entireChipBrand, + selected = selectedFilters.isEmpty() + ) { + onClickTotalChip() + } + } + items(brands) { brand -> + BrandChip( + brand = brand, + selected = selectedFilters.contains(brand.name) + ) { + onClickChip(brand) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AllBrandChipsDialog( + modifier: Modifier = Modifier, + brands: List = emptyList(), + showExpiredGifticon: Boolean = false, + selectedFilters: Set = emptySet(), + onCheckFilterExpired: (Boolean) -> Unit = {}, + onClickChip: (Brand) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val interactionSource = remember { MutableInteractionSource() } + Dialog( + onDismissRequest = { onDismiss() }, + properties = DialogProperties( + usePlatformDefaultWidth = false // 다이얼로그 너비 제한 제거 + ) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = interactionSource, // Ripple 효과 제거 + indication = null + ) { + onDismiss() + } + ) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colors.background + ) { + Column { + TextCheckbox( + checked = showExpiredGifticon, + textStyle = MaterialTheme.typography.body2, + text = stringResource(R.string.gifticon_list_brands_dialog_show_expired_gifticon_option) + ) { checked -> + onCheckFilterExpired(checked) + } + val scrollState = rememberScrollState() + FlowRow( + modifier = Modifier + .padding(16.dp) + .verticalScroll(scrollState), + mainAxisAlignment = FlowMainAxisAlignment.Start, + mainAxisSpacing = 8.dp, + mainAxisSize = SizeMode.Expand + ) { + brands.forEach { + BrandChip( + brand = it, + selected = selectedFilters.contains(it.name) + ) { selected -> + onClickChip(selected) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun BrandChip( + brand: Brand, + modifier: Modifier = Modifier, + selected: Boolean = false, + onClick: (Brand) -> Unit = {} +) { + FilterChip( + selected = selected, + onClick = { + onClick(brand) + }, + modifier = modifier.wrapContentWidth(), + colors = ChipDefaults.filterChipColors( + backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.1f), + contentColor = MaterialTheme.colors.onSurface, + selectedBackgroundColor = MaterialTheme.colors.primary, + selectedContentColor = Color.White + ) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = brand.name, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = brand.count.toString(), + modifier = Modifier.padding(start = 4.dp), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f) + ) + } + } +} + +@Composable +fun BrandChipLoadingScreen(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + BrandChipLoadingList( + Modifier + .weight(1f) + .padding(end = 16.dp), + 5 + ) + Icon( + modifier = Modifier.placeholder( + visible = true, + highlight = PlaceholderHighlight.shimmer() + ), + imageVector = Icons.Outlined.Tune, + contentDescription = null + ) + } +} + +@Composable +fun BrandChipLoadingList(modifier: Modifier = Modifier, count: Int = 3) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(count) { + BrandChipLoading() + } + } +} + +@Composable +fun BrandChipLoading(modifier: Modifier = Modifier) { + Spacer( + modifier = modifier + .size(60.dp, 30.dp) + .clip(RoundedCornerShape(16.dp)) + .placeholder( + visible = true, + highlight = PlaceholderHighlight.shimmer() + ) + ) +} + +@Preview(widthDp = 320, heightDp = 700) +@Composable +fun BrandChipLoadingPreview() { + BrandChipLoading() +} + +@Preview +@Composable +fun BrandChipLoadingListPreview() { + BrandChipLoadingList() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonList.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonList.kt new file mode 100644 index 000000000..a961ee27a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonList.kt @@ -0,0 +1,336 @@ +package com.lighthouse.presentation.ui.gifticonlist.component + +import android.content.Intent +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FractionalThreshold +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.rememberSwipeableState +import androidx.compose.material.swipeable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.placeholder.PlaceholderHighlight +import com.google.accompanist.placeholder.material.placeholder +import com.google.accompanist.placeholder.material.shimmer +import com.lighthouse.domain.util.isExpired +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.dpToPx +import com.lighthouse.presentation.extension.toConcurrency +import com.lighthouse.presentation.extension.toDday +import com.lighthouse.presentation.extension.toExpireDate +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.model.GifticonUIModel +import com.lighthouse.presentation.ui.detailgifticon.GifticonDetailActivity +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.glide.GlideImage +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun GifticonList( + gifticons: List, + modifier: Modifier = Modifier, + onUse: (GifticonUIModel) -> Unit = {}, + onRemove: (GifticonUIModel) -> Unit = {} +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + contentPadding = PaddingValues(vertical = 36.dp) + ) { + items(gifticons, key = { it.id }) { gifticon -> + GifticonItem( + gifticon = gifticon, + onUse = { onUse(it) }, + onRemove = { onRemove(it) } + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun GifticonItem(gifticon: GifticonUIModel, onUse: (GifticonUIModel) -> Unit = {}, onRemove: (GifticonUIModel) -> Unit = {}) { + val context = LocalContext.current + val cornerSize = 8.dp + + val swipeableState = rememberSwipeableState(initialValue = 0) + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(cornerSize)) + .swipeable( + state = swipeableState, + anchors = mapOf( + 0f to 0, + -(100f.dpToPx) to 1, + (100f.dpToPx) to 2 + ), + thresholds = { _, _ -> + FractionalThreshold(0.3f) + }, + orientation = Orientation.Horizontal + ) + .background(if (swipeableState.offset.value < 0) colorResource(id = R.color.point_green_dark) else Color.Red) + ) { + TextButton( + onClick = { + onRemove(gifticon) + scope.launch { + swipeableState.animateTo(0, tween(600, 0)) + } + }, + modifier = Modifier.align(Alignment.CenterStart).padding(start = 20.dp).wrapContentWidth() + ) { + Text( + text = stringResource(id = R.string.all_remove), + style = MaterialTheme.typography.button.copy(fontSize = 16.sp), + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + TextButton( + onClick = { + onUse(gifticon) + scope.launch { + swipeableState.animateTo(0, tween(600, 0)) + } + }, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 20.dp).wrapContentWidth() + ) { + Text( + text = stringResource(id = R.string.gifticon_list_use_complete), + style = MaterialTheme.typography.button.copy(fontSize = 16.sp), + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + + Card( + modifier = Modifier + .offset { + IntOffset(swipeableState.offset.value.roundToInt(), 0) + } + .fillMaxWidth() + .height(130.dp), + shape = MaterialTheme.shapes.medium.copy(CornerSize(cornerSize)), + onClick = { + context.startActivity( + Intent(context, GifticonDetailActivity::class.java).apply { + putExtra(Extras.KEY_GIFTICON_ID, gifticon.id) + } + ) + } + ) { + Row { + GlideImage( + imageModel = { gifticon.croppedUri }, + imageOptions = ImageOptions( + contentScale = ContentScale.Crop, + contentDescription = stringResource(R.string.gifticon_product_image), + alignment = Alignment.Center + ), + modifier = Modifier + .fillMaxHeight() + .clip(RoundedCornerShape(topStart = cornerSize, bottomStart = cornerSize)) + .aspectRatio(1f) + .align(Alignment.CenterVertically) + ) + Box( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colors.surface) + ) { + Text( + text = gifticon.expireAt.toDday(context), + modifier = Modifier + .clip(RoundedCornerShape(cornerSize)) + .background( + if (gifticon.expireAt.isExpired()) { + Color.Gray + } else { + MaterialTheme.colors.primary + } + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + .align(Alignment.TopEnd), + color = Color.White, + style = MaterialTheme.typography.caption + ) + Text( + text = gifticon.expireAt.toExpireDate(context), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 16.dp, end = 16.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = gifticon.brand, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) + ) + Text( + text = gifticon.name, + maxLines = 1, + style = MaterialTheme.typography.body1, + overflow = TextOverflow.Ellipsis + ) + if (gifticon.isCashCard) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = gifticon.balance.toConcurrency(context, true), + color = colorResource(R.color.beep_pink) + ) + } else { + Spacer(Modifier.height(16.dp)) + } + } + } + } + } + } +} + +@Composable +fun GifticonLoadingList(count: Int = 5) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 36.dp) + ) { + items(count) { + GifticonLoadingItem() + } + } +} + +@Composable +fun GifticonLoadingItem() { + val cornerSize = 8.dp + + Card( + modifier = Modifier + .fillMaxWidth() + .height(130.dp), + shape = MaterialTheme.shapes.medium.copy(CornerSize(cornerSize)) + ) { + Row { + Spacer( + modifier = Modifier.fillMaxHeight() + .clip(RoundedCornerShape(topStart = cornerSize, bottomStart = cornerSize)) + .aspectRatio(1f) + .align(Alignment.CenterVertically) + .placeholder( + visible = true, + highlight = PlaceholderHighlight.shimmer() + ) + ) + Box( + modifier = Modifier.fillMaxSize() + ) { + Text( + text = "D-00", + modifier = Modifier + .clip(RoundedCornerShape(cornerSize)) + .placeholder( + visible = true, + highlight = PlaceholderHighlight.shimmer() + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + .align(Alignment.TopEnd) + ) + Text( + text = "~ 2022.00.00", + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 16.dp, end = 16.dp) + .placeholder( + visible = true, + highlight = PlaceholderHighlight.shimmer() + ) + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier + .padding(bottom = 4.dp) + .placeholder( + visible = true, + highlight = PlaceholderHighlight.shimmer() + ), + text = "브랜드 자리" + ) + Text( + modifier = Modifier + .placeholder( + visible = true, + highlight = PlaceholderHighlight.shimmer() + ), + text = "제목이 들어갈 자리입니다" + ) + } + } + } + } +} + +@Preview +@Composable +fun GifticonLoadingPreview() { + GifticonLoadingItem() +} + +@Preview +@Composable +fun GifticonListLoadingPreview() { + GifticonLoadingList(3) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonListScreen.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonListScreen.kt new file mode 100644 index 000000000..18f26d732 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonListScreen.kt @@ -0,0 +1,127 @@ +package com.lighthouse.presentation.ui.gifticonlist.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.AlertDialog +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.lighthouse.presentation.R +import com.lighthouse.presentation.model.GifticonUIModel +import com.lighthouse.presentation.ui.gifticonlist.GifticonListViewModel + +@OptIn(ExperimentalLifecycleComposeApi::class) +@Composable +fun GifticonListScreen( + modifier: Modifier = Modifier, + viewModel: GifticonListViewModel = viewModel() +) { + val viewState by viewModel.state.collectAsStateWithLifecycle() + var removeGifticonDialogState by remember { mutableStateOf(null) } + + Surface( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.05f)) + .padding(horizontal = 16.dp), + color = Color.Transparent + ) { + Column { + if (viewState.loading) { + BrandChipLoadingScreen(modifier = Modifier.padding(top = 24.dp)) + } else { + BrandChipListScreen( + modifier = Modifier.padding(top = 24.dp), + brands = viewState.brands, + filters = viewState.selectedFilter, + onClickEntireBrandDialog = { + viewModel.showEntireBrandsDialog() + }, + onClickTotalChip = { + viewModel.clearFilter() + }, + onClickChip = { + viewModel.toggleFilterSelection(it) + } + ) + } + if (viewState.loading) { + GifticonLoadingList() + } else { + GifticonList( + gifticons = viewState.gifticons, + Modifier.padding(top = 8.dp), + onUse = { + viewModel.completeUsage(it) + }, + onRemove = { + removeGifticonDialogState = it + } + ) + } + } + if (viewState.entireBrandsDialogShown) { + AllBrandChipsDialog( + brands = viewState.brands, + modifier = Modifier + .padding(16.dp), + selectedFilters = viewState.selectedFilter, + showExpiredGifticon = viewState.showExpiredGifticon, + onCheckFilterExpired = { + viewModel.filterUsedGifticon(it) + }, + onClickChip = { + viewModel.toggleFilterSelection(it) + }, + onDismiss = { + viewModel.dismissEntireBrandsDialog() + } + ) + } + } + if (removeGifticonDialogState != null) { + AlertDialog( + onDismissRequest = { removeGifticonDialogState = null }, + title = { + Row { + Image( + imageVector = Icons.Default.Warning, + contentDescription = null, + colorFilter = ColorFilter.tint(Color.Red) + ) + Text(stringResource(R.string.gifticon_list_remove_gifticon_dialog_title)) + } + }, + text = { Text(stringResource(R.string.gifticon_list_remove_gifticon_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.removeGifticon(gifticon = removeGifticonDialogState ?: return@TextButton) + removeGifticonDialogState = null + } + ) { + Text(stringResource(R.string.gifticon_list_remove_gifticon_dialog_remove_button)) + } + } + ) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonListToolBar.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonListToolBar.kt new file mode 100644 index 000000000..7177a2595 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/GifticonListToolBar.kt @@ -0,0 +1,109 @@ +package com.lighthouse.presentation.ui.gifticonlist.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.lighthouse.presentation.R +import com.lighthouse.presentation.model.GifticonSortBy +import com.lighthouse.presentation.ui.gifticonlist.GifticonListViewModel + +@OptIn(ExperimentalLifecycleComposeApi::class) +@Composable +fun GifticonAppBar( + viewModel: GifticonListViewModel = viewModel() +) { + val viewState = viewModel.state.collectAsStateWithLifecycle() + val sortBy: GifticonSortBy = viewState.value.sortBy + + var expanded by remember { mutableStateOf(false) } + + TopAppBar( + title = {}, + backgroundColor = if (MaterialTheme.colors.isLight) { + MaterialTheme.colors.primary + } else { + MaterialTheme.colors.onSurface.copy(0.09f) + }, + actions = { + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .clickable { + expanded = true + }.align(Alignment.Center), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = sortBy.stringRes), + color = Color.White, + style = MaterialTheme.typography.h6 + ) + Icon( + modifier = Modifier.align(Alignment.CenterVertically), + imageVector = Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.gifticon_list_toolbar_dropdown_icon_description), + tint = Color.White + ) + } + } + + DropDownToolbarMenu( + expanded = expanded, + onDismiss = { expanded = false } + ) + } + ) +} + +@Composable +fun DropDownToolbarMenu( + modifier: Modifier = Modifier, + expanded: Boolean = false, + viewModel: GifticonListViewModel = viewModel(), + onDismiss: () -> Unit = {} +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { onDismiss() }, + modifier = modifier.fillMaxWidth() + ) { + GifticonSortBy.values().forEach { + DropdownMenuItem( + onClick = { + viewModel.sort(it) + onDismiss() + } + ) { + Text( + text = stringResource(id = it.stringRes), + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colors.onSurface, + textAlign = TextAlign.Center + ) + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/Preview.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/Preview.kt new file mode 100644 index 000000000..4f10df3b3 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/gifticonlist/component/Preview.kt @@ -0,0 +1,119 @@ +package com.lighthouse.presentation.ui.gifticonlist.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.lighthouse.domain.model.Brand +import com.lighthouse.domain.model.Gifticon + +val sampleGifticonItems = listOf( +// Gifticon( +// id = "sample1", +// createdAt = Date(), +// userId = "mangbaam", +// hasImage = false, +// name = "별다방 아메리카노", +// brand = "스타벅스", +// expireAt = Date(), +// barcode = "808346588450", +// isCashCard = false, +// balance = 0, +// memo = "", +// isUsed = false +// ), +// Gifticon( +// id = "sample2", +// createdAt = Date(), +// userId = "mangbaam", +// name = "5만원권", +// hasImage = false, +// brand = "GS25", +// expireAt = Date(), +// barcode = "808346588450", +// isCashCard = true, +// balance = 50000, +// memo = "", +// isUsed = false +// ), +// Gifticon( +// id = "sample3", +// createdAt = Date(), +// userId = "mangbaam", +// name = "어머니는 외계인", +// brand = "베스킨라빈스", +// expireAt = Date(), +// hasImage = false, +// barcode = "808346588450", +// isCashCard = false, +// balance = 0, +// memo = "", +// isUsed = true +// ), +// Gifticon( +// id = "sample4", +// createdAt = Date(), +// userId = "mangbaam", +// name = "3만원권", +// brand = "e마트", +// expireAt = Date(), +// barcode = "808346588450", +// isCashCard = true, +// balance = 0, +// hasImage = false, +// memo = "", +// isUsed = true +// ) +) + +@Preview +@Composable +fun ChipPreview() { + BrandChip(Brand("스타벅스", 10)) +} + +@Preview +@Composable +fun BrandChipsPreview() { + BrandChipList( + brands = listOf( + Brand("스타벅스", 10), + Brand("베스킨라빈스", 12), + Brand("맘스터치", 1), + Brand("김밥천국", 3), + Brand("투썸", 7) + ) + ) +} + +// @Preview(widthDp = 320) +// @Composable +// fun GifticonItemPreview() { +// GifticonItem( +// +// // sampleGifticonItems[0] +// ) +// } +// +// @Preview +// @Composable +// fun GifticonListPreview() { +// GifticonList( +// sampleGifticonItems +// ) +// } + +@Preview +@Composable +fun BrandChipsDialogPreview() { + AllBrandChipsDialog( + brands = listOf( + Brand(name = "스타벅스", count = 18), + Brand(name = "베스킨라빈스", count = 18), + Brand(name = "BHC", count = 18), + Brand(name = "GS25", count = 18), + Brand(name = "CU", count = 18), + Brand(name = "서브웨이", count = 18), + Brand(name = "세븐일레븐", count = 18), + Brand(name = "파파존스", count = 18) + ) + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeEmptyFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeEmptyFragment.kt new file mode 100644 index 000000000..d2839fad1 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeEmptyFragment.kt @@ -0,0 +1,24 @@ +package com.lighthouse.presentation.ui.home + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentHomeEmptyBinding +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.main.MainViewModel +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HomeEmptyFragment : Fragment(R.layout.fragment_home_empty) { + + private val binding: FragmentHomeEmptyBinding by viewBindings() + private val mainViewModel: MainViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.lifecycleOwner = viewLifecycleOwner + binding.vm = mainViewModel + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeEvent.kt new file mode 100644 index 000000000..192495c2a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeEvent.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.ui.home + +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.presentation.model.BrandPlaceInfoUiModel + +sealed class HomeEvent { + + data class NavigateMap(val gifticons: List, val nearBrandsInfo: List) : HomeEvent() + object RequestLocationPermissionCheck : HomeEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeFragment.kt new file mode 100644 index 000000000..c4ad1b5b3 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeFragment.kt @@ -0,0 +1,186 @@ +package com.lighthouse.presentation.ui.home + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.domain.model.Gifticon +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentHomeBinding +import com.lighthouse.presentation.extension.dp +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extension.screenWidth +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.model.BrandPlaceInfoUiModel +import com.lighthouse.presentation.ui.common.GifticonViewHolderType +import com.lighthouse.presentation.ui.common.UiState +import com.lighthouse.presentation.ui.common.dialog.ConfirmationDialog +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.detailgifticon.GifticonDetailActivity +import com.lighthouse.presentation.ui.home.adapter.NearGifticonAdapter +import com.lighthouse.presentation.ui.main.MainViewModel +import com.lighthouse.presentation.ui.map.MapActivity +import com.lighthouse.presentation.ui.map.adapter.GifticonAdapter +import com.lighthouse.presentation.util.permission.LocationPermissionManager +import com.lighthouse.presentation.util.permission.core.permissions +import com.lighthouse.presentation.util.recycler.ListSpaceItemDecoration +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest + +@AndroidEntryPoint +class HomeFragment : Fragment(R.layout.fragment_home) { + + private val binding: FragmentHomeBinding by viewBindings() + private val homeViewModel: HomeViewModel by viewModels({ requireParentFragment() }) + private val mainViewModel: MainViewModel by activityViewModels() + private val locationPermission: LocationPermissionManager by permissions() + + private val locationPermissionDialog by lazy { + val title = getString(R.string.confirmation_title) + val message = getString(R.string.confirmation_location_message) + ConfirmationDialog().apply { + setTitle(title) + setMessage(message) + setOnOkClickListener { + val intent = Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.fromParts("package", requireActivity().packageName, null) + } + startActivity(intent) + } + } + } + + private val contract = ActivityResultContracts.RequestMultiplePermissions() + + private val locationPermissionLauncher = + registerForActivityResult(contract) { results -> + if (results.all { it.value }.not()) { + locationPermissionDialog + .show(parentFragmentManager, ConfirmationDialog::class.java.name) + } + } + + private val nearGifticonAdapter = NearGifticonAdapter { gifticon -> + gotoGifticonDetail(gifticon.id) + } + private val expireGifticonAdapter = GifticonAdapter(GifticonViewHolderType.VERTICAL) { gifticon -> + gotoGifticonDetail(gifticon.id) + } + + private fun gotoGifticonDetail(id: String) { + startActivity( + Intent(requireContext(), GifticonDetailActivity::class.java).apply { + putExtra(Extras.KEY_GIFTICON_ID, id) + } + ) + } + + private val itemDecoration = ListSpaceItemDecoration( + space = 8.dp, + start = (screenWidth * 0.05).toFloat(), + top = 4.dp, + end = (screenWidth * 0.05).toFloat(), + bottom = 4.dp + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.lifecycleOwner = viewLifecycleOwner + binding.mainVm = mainViewModel + binding.homeVm = homeViewModel + observeLocationPermission() + setBindingAdapter() + setObserveViewModel() + } + + private fun observeLocationPermission() { + viewLifecycleOwner.repeatOnStarted { + locationPermission.permissionFlow.collectLatest { + homeViewModel.updateLocationPermission(it) + } + } + } + + private fun setBindingAdapter() { + with(binding.rvNearGifticon) { + adapter = nearGifticonAdapter + addItemDecoration(itemDecoration) + } + with(binding.rvExpireGifticon) { + adapter = expireGifticonAdapter + addItemDecoration(itemDecoration) + } + } + + private fun setObserveViewModel() { + viewLifecycleOwner.repeatOnStarted { + homeViewModel.uiState.collectLatest { state -> + when (state) { + is UiState.NetworkFailure -> showSnackBar(R.string.error_network_error) + is UiState.Failure -> showSnackBar(R.string.error_network_failure) + else -> Unit + } + } + } + + viewLifecycleOwner.repeatOnStarted { + homeViewModel.eventFlow.collectLatest { directions -> + when (directions) { + is HomeEvent.NavigateMap -> gotoMap(directions.gifticons, directions.nearBrandsInfo) + is HomeEvent.RequestLocationPermissionCheck -> launchPermission() + } + } + } + } + + private fun gotoMap( + gifticons: List = emptyList(), + nearBrandsInfo: List = emptyList() + ) { + when (locationPermission.isGrant) { + true -> startMapActivity(nearBrandsInfo, gifticons) + false -> launchPermission() + } + } + + private fun launchPermission() { + when { + shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) -> { + locationPermissionDialog + .show(parentFragmentManager, ConfirmationDialog::class.java.name) + } + else -> locationPermissionLauncher.launch(PERMISSIONS) + } + } + + private fun startMapActivity(nearBrandsInfo: List, gifticons: List) { + startActivity( + Intent(requireContext(), MapActivity::class.java).apply { + putExtra(Extras.KEY_NEAR_BRANDS, ArrayList(nearBrandsInfo)) + putExtra(Extras.KEY_NEAR_GIFTICONS, ArrayList(gifticons)) + } + ) + } + + private fun showSnackBar(@StringRes message: Int) { + Snackbar.make(binding.root, message, Snackbar.LENGTH_SHORT).show() + } + + override fun onStop() { + homeViewModel.cancelLocationCollectJob() + super.onStop() + } + + companion object { + val PERMISSIONS = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeFragmentContainer.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeFragmentContainer.kt new file mode 100644 index 000000000..02ec9ad52 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeFragmentContainer.kt @@ -0,0 +1,33 @@ +package com.lighthouse.presentation.ui.home + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentHomeContainerBinding +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.main.MainViewModel +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest + +@AndroidEntryPoint +class HomeFragmentContainer : Fragment(R.layout.fragment_home_container) { + + private val binding: FragmentHomeContainerBinding by viewBindings() + private val mainViewModel: MainViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.lifecycleOwner = viewLifecycleOwner + viewLifecycleOwner.repeatOnStarted { + mainViewModel.hasVariableGifticon.collectLatest { hasGifticon -> + when (hasGifticon) { + true -> childFragmentManager.commit { replace(R.id.fcv_home, HomeFragment()) } + false -> childFragmentManager.commit { replace(R.id.fcv_home, HomeEmptyFragment()) } + } + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeViewModel.kt new file mode 100644 index 000000000..7e4893db5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/home/HomeViewModel.kt @@ -0,0 +1,182 @@ +package com.lighthouse.presentation.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.LocationConverter.diffLocation +import com.lighthouse.domain.LocationConverter.setDmsLocation +import com.lighthouse.domain.VertexLocation +import com.lighthouse.domain.model.BeepError +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.usecase.GetBrandPlaceInfosUseCase +import com.lighthouse.domain.usecase.GetGifticonsUseCase +import com.lighthouse.domain.usecase.GetUserLocationUseCase +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.model.BrandPlaceInfoUiModel +import com.lighthouse.presentation.model.GifticonWithDistanceUIModel +import com.lighthouse.presentation.ui.common.UiState +import com.lighthouse.presentation.util.flow.MutableEventFlow +import com.lighthouse.presentation.util.flow.asEventFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + getGifticonUseCase: GetGifticonsUseCase, + private val getUserLocationUseCase: GetUserLocationUseCase, + private val getBrandPlaceInfosUseCase: GetBrandPlaceInfosUseCase +) : ViewModel() { + + private var locationFlow: Job? = null + + private val _eventFlow = MutableEventFlow() + val eventFlow = _eventFlow.asEventFlow() + + private val gifticons = + getGifticonUseCase.getUsableGifticons().stateIn(viewModelScope, SharingStarted.Eagerly, DbResult.Loading) + + private val allBrands = gifticons.transform { gifticons -> + if (gifticons is DbResult.Success) { + emit(gifticons.data.map { it.brand.lowercase() }.distinct()) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private val gifticonsMap = gifticons.transform { gifticons -> + if (gifticons is DbResult.Success) { + val gifticonGroup = gifticons.data.groupBy { it.brand.lowercase() } + emit(gifticonGroup) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap()) + + val expiredGifticon = gifticons.transform { gifticons -> + if (gifticons is DbResult.Success) { + val data = gifticons.data.map { + it.toPresentation() + } + val gifticonSize = + if (data.size < EXPIRED_GIFTICON_LIST_MAX_SIZE) data.size else EXPIRED_GIFTICON_LIST_MAX_SIZE + emit(data.slice(0 until gifticonSize)) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private val _uiState: MutableEventFlow> = MutableEventFlow() + val uiState = _uiState.asEventFlow() + + private var nearBrandsInfo = listOf() + + val isShimmer = MutableStateFlow(false) + + private val _nearGifticons: MutableStateFlow?> = MutableStateFlow(null) + val nearGifticons = _nearGifticons.asStateFlow() + + val isEmptyNearBrands = nearGifticons.transform { + val data = nearGifticons.value ?: kotlin.run { + emit(false) + return@transform + } + emit(data.isEmpty()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private var prevVertex = MutableStateFlow(null) + + var hasLocationPermission = MutableStateFlow(false) + private set + + init { + viewModelScope.launch { + hasLocationPermission.collectLatest { + if (it) observeLocationFlow() + } + } + } + + private fun observeLocationFlow() { + if (locationFlow?.isActive == true) return + isShimmer.value = true + + viewModelScope.launch { + combineLocationGifticon() + } + + locationFlow = viewModelScope.launch { + getUserLocationUseCase().collectLatest { location -> + _uiState.emit(UiState.Loading) + val currentDms = setDmsLocation(location) + val prevDms = prevVertex.value?.let { setDmsLocation(it) } + if (prevDms != currentDms) { + isShimmer.value = true + prevVertex.value = location + } + } + } + } + + private suspend fun combineLocationGifticon() { + prevVertex.combine(gifticons) { location, _ -> + location + }.collectLatest { location -> + location ?: return@collectLatest + val x = location.longitude + val y = location.latitude + + getBrandPlaceInfosUseCase(allBrands.value, x, y, SEARCH_SIZE) + .mapCatching { brand -> brand.toPresentation() } + .onSuccess { brands -> + nearBrandsInfo = brands + _nearGifticons.value = nearBrandsInfo + .distinctBy { it.brandLowerName } + .mapNotNull { placeInfo -> + gifticonsMap.value[placeInfo.brandLowerName] + ?.first() + ?.toPresentation(diffLocation(placeInfo.x, placeInfo.y, x, y)) + } + when (_nearGifticons.value.isNullOrEmpty()) { + true -> _uiState.emit(UiState.NotFoundResults) + false -> _uiState.emit(UiState.Success(Unit)) + } + } + .onFailure { throwable -> + _uiState.emit( + when (throwable) { + BeepError.NetworkFailure -> UiState.NetworkFailure + else -> UiState.Failure + } + ) + } + isShimmer.value = false + } + } + + fun gotoMap() { + viewModelScope.launch { + _eventFlow.emit(HomeEvent.NavigateMap(gifticonsMap.value.values.flatten(), nearBrandsInfo)) + } + } + + fun requestLocationPermission() { + viewModelScope.launch { + _eventFlow.emit(HomeEvent.RequestLocationPermissionCheck) + } + } + + fun cancelLocationCollectJob() { + locationFlow?.cancel() + } + + fun updateLocationPermission(isLocationPermission: Boolean) { + hasLocationPermission.value = isLocationPermission + } + + companion object { + private const val SEARCH_SIZE = 15 + private const val EXPIRED_GIFTICON_LIST_MAX_SIZE = 7 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/home/adapter/NearGifticonAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/home/adapter/NearGifticonAdapter.kt new file mode 100644 index 000000000..f0215273b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/home/adapter/NearGifticonAdapter.kt @@ -0,0 +1,53 @@ +package com.lighthouse.presentation.ui.home.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.adapter.BindableListAdapter +import com.lighthouse.presentation.databinding.ItemNearGifticonVerticalBinding +import com.lighthouse.presentation.model.GifticonWithDistanceUIModel + +class NearGifticonAdapter( + private val onClick: (GifticonWithDistanceUIModel) -> Unit +) : BindableListAdapter(diff) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GifticonVerticalItemViewHolder { + return GifticonVerticalItemViewHolder(parent) + } + + override fun onBindViewHolder(holder: GifticonVerticalItemViewHolder, position: Int) { + holder.bind(currentList[position]) + } + + inner class GifticonVerticalItemViewHolder( + parent: ViewGroup, + private val binding: ItemNearGifticonVerticalBinding = ItemNearGifticonVerticalBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_near_gifticon_vertical, parent, false) + ) + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + onClick(currentList[absoluteAdapterPosition]) + } + } + + fun bind(gifticon: GifticonWithDistanceUIModel) { + binding.gifticon = NearGifticonDisplayModel(gifticon) + } + } + + companion object { + private val diff = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GifticonWithDistanceUIModel, newItem: GifticonWithDistanceUIModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: GifticonWithDistanceUIModel, newItem: GifticonWithDistanceUIModel): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/home/adapter/NearGifticonDisplayModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/home/adapter/NearGifticonDisplayModel.kt new file mode 100644 index 000000000..37fa90783 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/home/adapter/NearGifticonDisplayModel.kt @@ -0,0 +1,27 @@ +package com.lighthouse.presentation.ui.home.adapter + +import com.lighthouse.presentation.R +import com.lighthouse.presentation.model.GifticonWithDistanceUIModel +import com.lighthouse.presentation.util.resource.UIText + +class NearGifticonDisplayModel( + val item: GifticonWithDistanceUIModel +) { + fun distance(): UIText { + val meter = calculate(item.distance) + return when (meter > MINIMUM_MITER) { + true -> UIText.StringResource(R.string.home_near_gifticon_distance, meter) + false -> UIText.StringResource(R.string.home_near_gifticon_announce) + } + } + + private fun calculate(distance: Int): Int { + val div = distance / MINIMUM_CRITERIA_MITER + return div * MINIMUM_CRITERIA_MITER + } + + companion object { + private const val MINIMUM_CRITERIA_MITER = 10 + private const val MINIMUM_MITER = 100 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainActivity.kt new file mode 100644 index 000000000..c8d7ed2a7 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainActivity.kt @@ -0,0 +1,211 @@ +package com.lighthouse.presentation.ui.main + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isVisible +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivityMainBinding +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.edit.addgifticon.AddGifticonActivity +import com.lighthouse.presentation.ui.gifticonlist.GifticonListFragment +import com.lighthouse.presentation.ui.home.HomeFragmentContainer +import com.lighthouse.presentation.ui.map.MapActivity +import com.lighthouse.presentation.ui.security.SecurityActivity +import com.lighthouse.presentation.ui.setting.SettingFragment +import com.lighthouse.presentation.util.permission.StoragePermissionManager +import com.lighthouse.presentation.util.permission.core.permissions +import com.lighthouse.presentation.util.resource.UIText +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.first + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private val viewModel: MainViewModel by viewModels() + private val storagePermission: StoragePermissionManager by permissions() + + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + when (val currentFragment = supportFragmentManager.fragments.first { it.isVisible }) { + is HomeFragmentContainer -> finish() + is SettingFragment -> { + if (currentFragment.isSettingMainFragment()) { + viewModel.gotoHome() + } + } + else -> { + viewModel.gotoHome() + } + } + } + } + + private val storagePermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + gotoAddGifticon() + } + } + + private val gifticonListFragment by lazy { + supportFragmentManager.findFragmentByTag(GifticonListFragment::class.java.name) ?: GifticonListFragment() + } + private val homeFragment by lazy { + supportFragmentManager.findFragmentByTag(HomeFragmentContainer::class.java.name) ?: HomeFragmentContainer() + } + private val settingFragment by lazy { + supportFragmentManager.findFragmentByTag(SettingFragment::class.java.name) ?: SettingFragment() + } + + private val addGifticon = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + showSnackBar(UIText.StringResource(R.string.main_registration_completed)) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_main) + binding.apply { + lifecycleOwner = this@MainActivity + vm = viewModel + } + + onBackPressedDispatcher.addCallback(this, callback) + + checkUserPreferenceOption() + collectEvent() + collectPage() + collectFab() + collectBnv() + } + + private fun checkUserPreferenceOption() { + repeatOnStarted { + if (viewModel.isSecurityOptionExist.first().not()) { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.security_not_set)) + .setMessage(getString(R.string.security_not_set_description)) + .setNeutralButton(getString(R.string.all_not_use)) { dialog, which -> + viewModel.saveSecurityNotUse() + } + .setPositiveButton(getString(R.string.main_menu_setting)) { dialog, which -> + gotoSecurity() + } + .setCancelable(false) + .show() + } + + if (viewModel.isNotificationOptionExist.first().not()) { + viewModel.saveNotificationUse() + } + } + } + + private fun collectEvent() { + repeatOnStarted { + viewModel.eventFlow.collect { directions -> + when (directions) { + is MainEvent.NavigateAddGifticon -> gotoAddGifticon() + is MainEvent.NavigateMap -> gotoMap(directions.brand) + } + } + } + } + + private fun getFragment(page: MainPage): Fragment? { + return when (page) { + MainPage.LIST -> gifticonListFragment + MainPage.HOME -> homeFragment + MainPage.SETTING -> settingFragment + else -> null + } + } + + private fun collectPage() { + repeatOnStarted { + var prevPage = viewModel.pageFlow.value + viewModel.pageFlow.collect { page -> + val preFragment = getFragment(prevPage) + val fragment = getFragment(page) + if (fragment != null) { + supportFragmentManager.commit { + if (page.ordinal < prevPage.ordinal) { + setCustomAnimations(R.anim.anim_slide_in_left, R.anim.anim_slide_out_right) + } else if (page.ordinal > prevPage.ordinal) { + setCustomAnimations(R.anim.anim_slide_in_right, R.anim.anim_slide_out_left) + } + if (preFragment != null && preFragment != fragment) { + hide(preFragment) + } + if (fragment.isAdded) { + show(fragment) + } else { + add(R.id.fl_container, fragment, fragment.javaClass.name) + } + } + } + prevPage = page + } + } + } + + private fun collectFab() { + repeatOnStarted { + viewModel.fabFlow.collect { show -> + if (show) { + binding.fabAddGifticon.show() + } else { + binding.fabAddGifticon.hide() + } + } + } + } + + private fun collectBnv() { + repeatOnStarted { + viewModel.bnvFlow.collect { show -> + binding.bnv.isVisible = show + } + } + } + + private fun gotoAddGifticon() { + if (storagePermission.isGrant) { + val intent = Intent(this, AddGifticonActivity::class.java) + addGifticon.launch(intent) + } else { + storagePermissionLauncher.launch(storagePermission.basicPermission) + } + } + + private fun gotoMap(brand: String) { + val intent = Intent(this, MapActivity::class.java).apply { + putExtra(Extras.KEY_WIDGET_BRAND, brand) + } + startActivity(intent) + } + + private fun gotoSecurity() { + val intent = Intent(this, SecurityActivity::class.java).apply { + putExtra(Extras.KEY_PIN_REVISE, false) + } + startActivity(intent) + } + + private fun showSnackBar(uiText: UIText) { + Snackbar.make(binding.root, uiText.asString(applicationContext), Snackbar.LENGTH_SHORT).show() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainEvent.kt new file mode 100644 index 000000000..8724f3f9d --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainEvent.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.ui.main + +sealed class MainEvent { + + object NavigateAddGifticon : MainEvent() + data class NavigateMap(val brand: String) : MainEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainPage.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainPage.kt new file mode 100644 index 000000000..f04b7a9d7 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainPage.kt @@ -0,0 +1,5 @@ +package com.lighthouse.presentation.ui.main + +enum class MainPage { + LIST, HOME, SETTING, OTHER +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainViewModel.kt new file mode 100644 index 000000000..208e56648 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/main/MainViewModel.kt @@ -0,0 +1,139 @@ +package com.lighthouse.presentation.ui.main + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.usecase.HasVariableGifticonUseCase +import com.lighthouse.domain.usecase.setting.GetOptionStoredUseCase +import com.lighthouse.domain.usecase.setting.SaveNotificationOptionUseCase +import com.lighthouse.domain.usecase.setting.SaveSecurityOptionUseCase +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.setting.SecurityOption +import com.lighthouse.presentation.util.flow.MutableEventFlow +import com.lighthouse.presentation.util.flow.asEventFlow +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + hasVariableGifticonUseCase: HasVariableGifticonUseCase, + getOptionStoredUseCase: GetOptionStoredUseCase, + private val saveSecurityOptionUseCase: SaveSecurityOptionUseCase, + private val saveNotificationOptionUseCase: SaveNotificationOptionUseCase +) : ViewModel() { + + private val _eventFlow = MutableEventFlow() + val eventFlow = _eventFlow.asEventFlow() + + val selectedMenuItem = MutableStateFlow(R.id.menu_home) + + private val _pageFlow = MutableStateFlow(MainPage.HOME) + val pageFlow = _pageFlow + .onEach { page -> + pageToMenuId(page)?.let { menuId -> + gotoMenuItem(menuId) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, MainPage.HOME) + + val hasVariableGifticon = hasVariableGifticonUseCase() + + private val widgetEvent = savedStateHandle.get(Extras.KEY_WIDGET_EVENT) + private val widgetBrandName = savedStateHandle.get(Extras.KEY_WIDGET_BRAND) + + init { + viewModelScope.launch { + widgetEvent ?: return@launch + widgetBrandName ?: return@launch + when (widgetEvent) { + Extras.WIDGET_EVENT_MAP -> _eventFlow.emit(MainEvent.NavigateMap(widgetBrandName)) + } + } + } + + val fabFlow = _pageFlow.combine(hasVariableGifticon) { page, hasVariableGifticon -> + when (page) { + MainPage.HOME -> hasVariableGifticon + MainPage.LIST -> true + MainPage.SETTING, + MainPage.OTHER -> false + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, true) + + val bnvFlow = _pageFlow.map { page -> + when (page) { + MainPage.HOME, + MainPage.LIST, + MainPage.SETTING -> true + MainPage.OTHER -> false + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, true) + + val isSecurityOptionExist = getOptionStoredUseCase(UserPreferenceOption.SECURITY) + val isNotificationOptionExist = getOptionStoredUseCase(UserPreferenceOption.NOTIFICATION) + + fun gotoAddGifticon() { + viewModelScope.launch { + _eventFlow.emit(MainEvent.NavigateAddGifticon) + } + } + + fun gotoList() { + viewModelScope.launch { + _pageFlow.emit(MainPage.LIST) + } + } + + private fun pageToMenuId(page: MainPage): Int? { + return when (page) { + MainPage.LIST -> R.id.menu_list + MainPage.HOME -> R.id.menu_home + MainPage.SETTING -> R.id.menu_setting + else -> null + } + } + + fun gotoMenuItem(itemId: Int): Boolean { + if (selectedMenuItem.value == itemId) { + return true + } + selectedMenuItem.value = itemId + viewModelScope.launch { + val pages = when (itemId) { + R.id.menu_list -> MainPage.LIST + R.id.menu_home -> MainPage.HOME + R.id.menu_setting -> MainPage.SETTING + else -> MainPage.OTHER + } + _pageFlow.emit(pages) + } + return true + } + + fun saveSecurityNotUse() { + viewModelScope.launch { + saveSecurityOptionUseCase(SecurityOption.NONE.ordinal) + } + } + + fun saveNotificationUse() { + viewModelScope.launch { + saveNotificationOptionUseCase(true) + } + } + + fun gotoHome() { + viewModelScope.launch { + _pageFlow.emit(MainPage.HOME) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapActivity.kt new file mode 100644 index 000000000..c5ebec23c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapActivity.kt @@ -0,0 +1,329 @@ +package com.lighthouse.presentation.ui.map + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import androidx.databinding.DataBindingUtil +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.domain.LocationConverter.diffLocation +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivityMapBinding +import com.lighthouse.presentation.extension.dp +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extension.screenWidth +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.extra.Extras.CATEGORY_ACCOMMODATION +import com.lighthouse.presentation.extra.Extras.CATEGORY_CAFE +import com.lighthouse.presentation.extra.Extras.CATEGORY_CONVENIENCE +import com.lighthouse.presentation.extra.Extras.CATEGORY_CULTURE +import com.lighthouse.presentation.extra.Extras.CATEGORY_MART +import com.lighthouse.presentation.extra.Extras.CATEGORY_RESTAURANT +import com.lighthouse.presentation.model.BrandPlaceInfoUiModel +import com.lighthouse.presentation.ui.common.GifticonViewHolderType +import com.lighthouse.presentation.ui.common.UiState +import com.lighthouse.presentation.ui.detailgifticon.GifticonDetailActivity +import com.lighthouse.presentation.ui.map.adapter.GifticonAdapter +import com.lighthouse.presentation.util.recycler.ListSpaceItemDecoration +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.CameraAnimation +import com.naver.maps.map.CameraUpdate +import com.naver.maps.map.MapView +import com.naver.maps.map.NaverMap +import com.naver.maps.map.OnMapReadyCallback +import com.naver.maps.map.overlay.Marker +import com.naver.maps.map.overlay.OverlayImage +import com.naver.maps.map.util.FusedLocationSource +import com.naver.maps.map.widget.LocationButtonView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest + +@SuppressLint("MissingPermission") +@AndroidEntryPoint +class MapActivity : AppCompatActivity(), OnMapReadyCallback { + + private lateinit var binding: ActivityMapBinding + private lateinit var naverMap: NaverMap + private lateinit var mapView: MapView + private lateinit var client: FusedLocationProviderClient + private lateinit var fusedLocationSource: FusedLocationSource + private val viewModel: MapViewModel by viewModels() + private val gifticonAdapter = GifticonAdapter(GifticonViewHolderType.HORIZONTAL) { gifticon -> + startActivity( + Intent(this, GifticonDetailActivity::class.java).apply { + putExtra(Extras.KEY_GIFTICON_ID, gifticon.id) + } + ) + } + private val currentLocationButton: LocationButtonView by lazy { binding.btnCurrentLocation } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + client = LocationServices.getFusedLocationProviderClient(this) + binding = DataBindingUtil.setContentView(this, R.layout.activity_map) + binding.vm = viewModel + binding.lifecycleOwner = this + mapView = binding.mapView.apply { + onCreate(savedInstanceState) + getMapAsync(this@MapActivity) + } + setGifticonAdapterItem() + setGifticonAdapterChangeCallback() + setObserveEvent() + } + + private fun setGifticonAdapterItem() { + val pageMargin = screenWidth * 0.1 + val pagerWidth = screenWidth - 2 * pageMargin + val offsetPx = screenWidth - pageMargin.toInt() - pagerWidth.toInt() + with(binding.vpGifticon) { + adapter = gifticonAdapter + offscreenPageLimit = 3 + setPageTransformer { page, position -> + page.translationX = position * -offsetPx + } + addItemDecoration( + ListSpaceItemDecoration( + space = 48.dp, + start = 20.dp, + end = 24.dp + ) + ) + } + } + + override fun onStart() { + super.onStart() + mapView.onStart() + } + + override fun onMapReady(map: NaverMap) { + fusedLocationSource = FusedLocationSource(this, PERMISSION_REQUEST_CODE) + naverMap = map.apply { + this.locationSource = fusedLocationSource + setOnMapClickListener { _, _ -> + resetFocusMarker(viewModel.focusMarker) + viewModel.updateGifticons() + } + } + currentLocationButton.map = naverMap + + setInitSearchData() + setObserveSearchData() + setNaverMapZoom() + } + + // configuration change 일어날때 viewModel이 갖고 있는 marker 데이터 있는지 확인 + private fun setInitSearchData() { + viewModel.markerHolder.forEach { marker -> + marker.map = naverMap + } + } + + private fun setFocusMarker(marker: Marker) { + marker.iconTintColor = getColor(R.color.beep_pink) + marker.captionColor = getColor(R.color.beep_pink) + marker.zIndex = 1 + val location = marker.position + viewModel.updateFocusMarker(marker) + moveMapCamera(location.longitude, location.latitude) + } + + private fun setGifticonAdapterChangeCallback() { + binding.vpGifticon.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + if (viewModel.viewPagerFocus.not()) { + viewModel.updatePagerFocus(true) + return + } + if (isRecentSelected(gifticonAdapter.currentList[position].brandLowerName)) return + val currentItem = gifticonAdapter.currentList[position].brandLowerName + findBrandPlaceInfo(currentItem) + } + }) + } + + /** + * 하단 ViewPager2 PageChangeCallback 실행시 현재 위치에서 가장 가까운 데이터를 갖고 오는 로직 + * @param brandName 찾고자하는 브랜드명 + */ + private fun findBrandPlaceInfo(brandName: String, isLoadGifticonList: Boolean = false) { + client.lastLocation.addOnSuccessListener { currentLocation -> + val brandPlaceInfo = viewModel.brandInfos.filter { brandPlaceInfo -> + brandPlaceInfo.brandLowerName == brandName + }.minByOrNull { location -> + diffLocation(location.x, location.y, currentLocation.longitude, currentLocation.latitude) + } ?: return@addOnSuccessListener + + resetFocusMarker(viewModel.focusMarker) + + val currentFocusMarker = viewModel.markerHolder.find { + currentLocation(it, brandPlaceInfo) + } ?: return@addOnSuccessListener + moveMapCamera(brandPlaceInfo.x.toDouble(), brandPlaceInfo.y.toDouble()) + setFocusMarker(currentFocusMarker) + if (isLoadGifticonList) viewModel.updateGifticons() + } + } + + private fun currentLocation(it: Marker, brandPlaceInfo: BrandPlaceInfoUiModel) = + it.position.longitude == brandPlaceInfo.x.toDouble() && it.position.latitude == brandPlaceInfo.y.toDouble() + + private fun isRecentSelected(brand: String) = viewModel.recentSelectedMarker.captionText.lowercase() == brand + + private fun moveMapCamera(longitude: Double, latitude: Double) { + val cameraUpdate = CameraUpdate.scrollTo(LatLng(latitude, longitude)) + .animate(CameraAnimation.Easing) + naverMap.moveCamera(cameraUpdate) + } + + private fun setObserveSearchData() { + repeatOnStarted { + viewModel.state.collectLatest { state -> + when (state) { + is UiState.Success -> updateBrandMarker(state.item) + is UiState.Loading -> Unit + is UiState.NetworkFailure -> showSnackBar(R.string.error_network_error) + is UiState.NotFoundResults -> showSnackBar(R.string.error_not_found_results) + is UiState.Failure -> showSnackBar(R.string.error_network_failure) + } + } + } + } + + private fun setNaverMapZoom() { + naverMap.maxZoom = 18.0 + naverMap.minZoom = 7.0 + + client.lastLocation.addOnSuccessListener { startLocation -> + moveMapCamera(startLocation.longitude, startLocation.latitude) + } + } + + private fun updateBrandMarker(brandPlaceSearchResults: List) { + val brandMarkers = brandPlaceSearchResults.map { brandPlaceSearchResult -> + Marker().apply { + val latLng = LatLng(brandPlaceSearchResult.y.toDouble(), brandPlaceSearchResult.x.toDouble()) + setMarker(this, latLng, brandPlaceSearchResult) + } + } + viewModel.updateMarkers(brandMarkers) + + repeatOnStarted { + viewModel.widgetBrand.collect { brand -> + findBrandPlaceInfo(brand, true) + } + } + } + + private fun setMarker( + marker: Marker, + latLng: LatLng, + brandPlaceSearchResult: BrandPlaceInfoUiModel + ) { + with(marker) { + position = latLng + icon = setMarkerIcon(brandPlaceSearchResult.categoryName) + iconTintColor = getColor(R.color.point_green) + tag = brandPlaceSearchResult.placeUrl + map = naverMap + captionText = brandPlaceSearchResult.brand.uppercase() + isHideCollidedSymbols = true + zIndex = 0 + } + marker.setOnClickListener { + if (isSameMarker(marker)) return@setOnClickListener true + val curFocusBrand = viewModel.focusMarker + resetFocusMarker(curFocusBrand) + setFocusMarker(marker) + viewModel.updateFocusMarker(marker) + viewModel.updateGifticons() + true + } + } + + private fun setMarkerIcon(categoryName: String): OverlayImage { + return when (categoryName) { + CATEGORY_MART -> OverlayImage.fromResource(R.drawable.ic_marker_market) + CATEGORY_CONVENIENCE -> OverlayImage.fromResource(R.drawable.ic_marker_convenience) + CATEGORY_CULTURE -> OverlayImage.fromResource(R.drawable.ic_marker_culture) + CATEGORY_ACCOMMODATION -> OverlayImage.fromResource(R.drawable.ic_marker_accommodation) + CATEGORY_RESTAURANT -> OverlayImage.fromResource(R.drawable.ic_marker_restaurant) + CATEGORY_CAFE -> OverlayImage.fromResource(R.drawable.ic_marker_cafe) + else -> OverlayImage.fromResource(R.drawable.ic_marker_base) + } + } + + private fun isSameMarker(marker: Marker) = + marker.position.longitude == viewModel.focusMarker.position.longitude && + marker.position.latitude == viewModel.focusMarker.position.latitude + + private fun resetFocusMarker(marker: Marker) { + marker.zIndex = 0 + marker.iconTintColor = getColor(R.color.point_green) + marker.captionColor = getColor(R.color.black) + viewModel.resetMarker() + } + + private fun setObserveEvent() { + repeatOnStarted { + viewModel.event.collect { event -> + when (event) { + is MapEvent.DeleteMarker -> deleteMarker(event.marker) + is MapEvent.NavigateHome -> gotoHome() + } + } + } + } + + private fun deleteMarker(marker: List) { + marker.forEach { it.map = null } + } + + private fun gotoHome() { + finish() + } + + private fun showSnackBar(@StringRes message: Int) { + Snackbar.make(binding.layoutMap, message, Snackbar.LENGTH_SHORT).show() + } + + override fun onResume() { + super.onResume() + mapView.onResume() + } + + override fun onPause() { + mapView.onPause() + super.onPause() + } + + override fun onSaveInstanceState(outState: Bundle) { + mapView.onSaveInstanceState(outState) + super.onSaveInstanceState(outState) + } + + override fun onStop() { + mapView.onStop() + super.onStop() + } + + override fun onDestroy() { + mapView.onDestroy() + super.onDestroy() + } + + override fun onLowMemory() { + mapView.onLowMemory() + super.onLowMemory() + } + + companion object { + private const val PERMISSION_REQUEST_CODE = 100 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapEvent.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapEvent.kt new file mode 100644 index 000000000..5a957aadb --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapEvent.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.ui.map + +sealed class MapEvent { + + object NavigateHome : MapEvent() + data class DeleteMarker(val marker: List) : MapEvent() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapViewModel.kt new file mode 100644 index 000000000..ebf462c3c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/map/MapViewModel.kt @@ -0,0 +1,223 @@ +package com.lighthouse.presentation.ui.map + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.LocationConverter +import com.lighthouse.domain.VertexLocation +import com.lighthouse.domain.model.BeepError +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.usecase.GetBrandPlaceInfosUseCase +import com.lighthouse.domain.usecase.GetGifticonsUseCase +import com.lighthouse.domain.usecase.GetUserLocationUseCase +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.model.BrandPlaceInfoUiModel +import com.lighthouse.presentation.model.GifticonUIModel +import com.lighthouse.presentation.ui.common.UiState +import com.lighthouse.presentation.util.TimeCalculator +import com.lighthouse.presentation.util.flow.MutableEventFlow +import com.lighthouse.presentation.util.flow.asEventFlow +import com.naver.maps.map.overlay.Marker +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MapViewModel @Inject constructor( + getGifticonUseCase: GetGifticonsUseCase, + savedStateHandle: SavedStateHandle, + private val getBrandPlaceInfosUseCase: GetBrandPlaceInfosUseCase, + private val getUserLocation: GetUserLocationUseCase +) : ViewModel() { + + private val _state: MutableEventFlow>> = MutableEventFlow() + val state = _state.asEventFlow() + + var recentSelectedMarker = Marker() + private set + + var focusMarker = Marker() + private set + + private val _markerHolder = mutableSetOf() + val markerHolder: Set = _markerHolder + + private val _brandInfos = mutableSetOf() + val brandInfos: Set = _brandInfos + + private val resultGifticons = + getGifticonUseCase.getUsableGifticons().stateIn(viewModelScope, SharingStarted.Eagerly, DbResult.Loading) + + private val allGifticons = resultGifticons.transform { gifticons -> + if (gifticons is DbResult.Success) { + emit(gifticons.data.sortedBy { TimeCalculator.formatDdayToInt(it.expireAt.time) }) + } else if (gifticons is DbResult.Empty) { + emit(emptyList()) + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + private val allBrands = allGifticons.transform { gifticons -> + emit(gifticons.map { it.brand.lowercase() }.distinct()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val _gifticonData = MutableStateFlow>(emptyList()) + val gifticonData = _gifticonData.asStateFlow() + + private val _event = MutableEventFlow() + val event = _event.asEventFlow() + + private val _widgetBrand = MutableEventFlow() + val widgetBrand = _widgetBrand.asEventFlow() + + private var prevVertex = MutableStateFlow(null) + var viewPagerFocus = false + private set + + private var removeMarker = allBrands.transform { + val brands = it ?: return@transform + val removeMarkers = markerHolder.filter { marker -> brands.contains(marker.captionText.lowercase()).not() } + emit(removeMarkers) + } + + init { + setRemoveMarker() + val isFirstLoadData = checkHomeData(savedStateHandle) + combineLocationGifticon() + collectLocation(isFirstLoadData) + } + + private fun checkHomeData(savedStateHandle: SavedStateHandle): Boolean { + var isFirstLoadData = true + val nearBrands = savedStateHandle.get>(Extras.KEY_NEAR_BRANDS) + val nearGifticons = savedStateHandle.get>(Extras.KEY_NEAR_GIFTICONS) + viewModelScope.launch { + if (nearBrands.isNullOrEmpty() || nearGifticons.isNullOrEmpty()) { + isFirstLoadData = false + } else { + // homeActivity에서 받은 데이터가 있는 경우에만 실행 + _brandInfos.addAll(nearBrands) + _state.emit(UiState.Success(nearBrands)) + updateGifticons() + } + val brand = savedStateHandle.get(Extras.KEY_WIDGET_BRAND) ?: return@launch + _widgetBrand.emit(brand) + } + return isFirstLoadData + } + + private fun setRemoveMarker() { + viewModelScope.launch { + removeMarker.collectLatest { + _markerHolder.removeAll(it.toSet()) + _event.emit(MapEvent.DeleteMarker(it)) + resetMarker() + updatePagerFocus(false) + updateGifticons() + } + } + } + + private fun collectLocation(isFirstLoadData: Boolean) { + viewModelScope.launch { + var isNeededFirstLoading = isFirstLoadData + getUserLocation().collectLatest { location -> + if (isNeededFirstLoading) { + isNeededFirstLoading = false + return@collectLatest + } + val currentDms = LocationConverter.setDmsLocation(location) + val prevDms = prevVertex.value?.let { LocationConverter.setDmsLocation(it) } + + if (prevDms != currentDms) prevVertex.value = location + } + } + } + + private fun combineLocationGifticon() { + viewModelScope.launch { + prevVertex.combine(allGifticons) { location, _ -> + location + }.collectLatest { location -> + location ?: run { + updateGifticons() + return@collectLatest + } + val brands = allBrands.value ?: return@collectLatest + + _state.emit(UiState.Loading) + getBrandPlaceInfosUseCase(brands, location.longitude, location.latitude, SEARCH_SIZE) + .mapCatching { it.toPresentation() } + .onSuccess { brandPlaceInfos -> + val diffBrandPlaceInfo = brandPlaceInfos.filter { + brandInfos.contains(it).not() + } + _brandInfos.addAll(brandPlaceInfos) + when (brandInfos.isEmpty()) { + true -> _state.emit(UiState.NotFoundResults) + false -> { + _state.emit(UiState.Success(diffBrandPlaceInfo)) + updateGifticons() + } + } + } + .onFailure { throwable -> + _state.emit( + when (throwable) { + BeepError.NetworkFailure -> UiState.NetworkFailure + else -> UiState.Failure + } + ) + } + } + } + } + + fun updateFocusMarker(marker: Marker) { + recentSelectedMarker = marker + focusMarker = marker + } + + fun updateMarkers(brandMarkers: List) { + _markerHolder.addAll(brandMarkers) + updateGifticons() + } + + fun updateGifticons() { + val brandName = focusMarker.captionText.lowercase() + _gifticonData.value = allGifticons.value.filter { gifticon -> + if (brandName.isNotEmpty()) { + gifticon.brand.lowercase() == brandName + } else { + brandInfos.find { it.brandLowerName == gifticon.brand.lowercase() } != null + } + }.map { + it.toPresentation() + } + } + + fun resetMarker() { + focusMarker = Marker() + } + + fun gotoHome() { + viewModelScope.launch { + _event.emit(MapEvent.NavigateHome) + } + } + + fun updatePagerFocus(isPagerFocus: Boolean) { + viewPagerFocus = isPagerFocus + } + + companion object { + private const val SEARCH_SIZE = 15 + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/map/OnLocationUpdateListener.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/map/OnLocationUpdateListener.kt new file mode 100644 index 000000000..625d2b59b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/map/OnLocationUpdateListener.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.ui.map + +import android.location.Location + +interface OnLocationUpdateListener { + fun onLocationUpdated(location: Location) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/map/adapter/GifticonAdapter.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/map/adapter/GifticonAdapter.kt new file mode 100644 index 000000000..aa83fcbcb --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/map/adapter/GifticonAdapter.kt @@ -0,0 +1,80 @@ +package com.lighthouse.presentation.ui.map.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.lighthouse.presentation.R +import com.lighthouse.presentation.adapter.BindableListAdapter +import com.lighthouse.presentation.databinding.ItemGifticonHorizontalBinding +import com.lighthouse.presentation.databinding.ItemGifticonVerticalBinding +import com.lighthouse.presentation.model.GifticonUIModel +import com.lighthouse.presentation.ui.common.GifticonViewHolderType + +class GifticonAdapter( + private val gifticonViewHolderType: GifticonViewHolderType, + private val onClick: (GifticonUIModel) -> Unit +) : BindableListAdapter(diff) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + return when (gifticonViewHolderType) { + GifticonViewHolderType.VERTICAL -> GifticonVerticalItemViewHolder(parent) + GifticonViewHolderType.HORIZONTAL -> GifticonHorizontalItemViewHolder(parent) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is GifticonVerticalItemViewHolder -> holder.bind(currentList[position]) + is GifticonHorizontalItemViewHolder -> holder.bind(currentList[position]) + } + } + + inner class GifticonHorizontalItemViewHolder( + parent: ViewGroup, + private val binding: ItemGifticonHorizontalBinding = ItemGifticonHorizontalBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_gifticon_horizontal, parent, false) + ) + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + onClick(currentList[absoluteAdapterPosition]) + } + } + + fun bind(gifticon: GifticonUIModel) { + binding.gifticon = gifticon + } + } + + inner class GifticonVerticalItemViewHolder( + parent: ViewGroup, + private val binding: ItemGifticonVerticalBinding = ItemGifticonVerticalBinding.bind( + LayoutInflater.from(parent.context).inflate(R.layout.item_gifticon_vertical, parent, false) + ) + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + onClick(currentList[absoluteAdapterPosition]) + } + } + + fun bind(gifticon: GifticonUIModel) { + binding.gifticon = gifticon + } + } + + companion object { + private val diff = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: GifticonUIModel, newItem: GifticonUIModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: GifticonUIModel, newItem: GifticonUIModel): Boolean { + return oldItem == newItem + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/AuthCallback.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/AuthCallback.kt new file mode 100644 index 000000000..976897f2f --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/AuthCallback.kt @@ -0,0 +1,9 @@ +package com.lighthouse.presentation.ui.security + +import androidx.annotation.StringRes + +interface AuthCallback { + fun onAuthSuccess() + fun onAuthCancel() + fun onAuthError(@StringRes stringId: Int? = null) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/AuthManager.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/AuthManager.kt new file mode 100644 index 000000000..1965ff7aa --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/AuthManager.kt @@ -0,0 +1,55 @@ +package com.lighthouse.presentation.ui.security + +import android.content.Intent +import androidx.activity.result.ActivityResultLauncher +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import com.lighthouse.domain.usecase.setting.GetSecurityOptionUseCase +import com.lighthouse.presentation.ui.security.fingerprint.biometric.BiometricAuth +import com.lighthouse.presentation.ui.security.pin.PinDialog +import com.lighthouse.presentation.ui.setting.SecurityOption +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +class AuthManager @Inject constructor( + getSecurityOptionUseCase: GetSecurityOptionUseCase +) { + + private val securityOption: StateFlow = + getSecurityOptionUseCase().map { + SecurityOption.values()[it] + }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, SecurityOption.NONE) + + fun auth( + activity: FragmentActivity, + biometricLauncher: ActivityResultLauncher, + authCallback: AuthCallback + ) { + when (securityOption.value) { + SecurityOption.NONE -> authCallback.onAuthSuccess() + SecurityOption.PIN -> authPin(activity.supportFragmentManager, authCallback) + SecurityOption.FINGERPRINT -> authFingerprint(activity, biometricLauncher, authCallback) + } + } + + private fun authPin(supportFragmentManager: FragmentManager, authCallback: AuthCallback) { + PinDialog(authCallback).show(supportFragmentManager, PIN_TAG) + } + + fun authFingerprint( + activity: FragmentActivity, + biometricLauncher: ActivityResultLauncher, + authCallback: AuthCallback + ) { + BiometricAuth(activity, biometricLauncher, authCallback).authenticate() + } + + companion object { + private const val PIN_TAG = "PIN" + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/CheckLocationFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/CheckLocationFragment.kt new file mode 100644 index 000000000..8f4b3d2f4 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/CheckLocationFragment.kt @@ -0,0 +1,32 @@ +package com.lighthouse.presentation.ui.security + +import android.Manifest +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.lighthouse.presentation.R +import com.lighthouse.presentation.ui.security.event.SecurityDirections + +class CheckLocationFragment : Fragment(R.layout.fragment_check_location) { + + private val activityViewModel: SecurityViewModel by activityViewModels() + + private val contract = ActivityResultContracts.RequestMultiplePermissions() + private val locationPermissionLauncher = + registerForActivityResult(contract) { + activityViewModel.gotoOtherScreen(SecurityDirections.MAIN) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + locationPermissionLauncher.launch(PERMISSIONS) + } + + companion object { + private val PERMISSIONS = + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/SecurityActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/SecurityActivity.kt new file mode 100644 index 000000000..93879c83f --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/SecurityActivity.kt @@ -0,0 +1,68 @@ +package com.lighthouse.presentation.ui.security + +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit +import com.lighthouse.presentation.R +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.ui.main.MainActivity +import com.lighthouse.presentation.ui.security.event.SecurityDirections +import com.lighthouse.presentation.ui.security.fingerprint.FingerprintFragment +import com.lighthouse.presentation.ui.security.pin.PinFragment +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SecurityActivity : AppCompatActivity() { + + private val viewModel: SecurityViewModel by viewModels() + private val fingerprintFragment by lazy { FingerprintFragment() } + private val pinFragment by lazy { PinFragment() } + private val checkLocationFragment by lazy { CheckLocationFragment() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_security) + + moveScreen(SecurityDirections.PIN) + repeatOnStarted { + viewModel.directionsFlow.collect { directions -> + navigate(directions) + } + } + } + + private fun navigate(directions: SecurityDirections) { + when (directions) { + SecurityDirections.MAIN -> gotoMain() + else -> moveScreen(directions) + } + } + + private fun gotoMain() { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + startActivity(intent) + } + + private fun moveScreen(directions: SecurityDirections) { + val fragment = when (directions) { + SecurityDirections.FINGERPRINT -> fingerprintFragment + SecurityDirections.PIN -> pinFragment + SecurityDirections.LOCATION -> checkLocationFragment + else -> return + } + supportFragmentManager.commit { + if (fragment != fingerprintFragment && fingerprintFragment.isAdded) hide(fingerprintFragment) + if (fragment != pinFragment && pinFragment.isAdded) hide(pinFragment) + if (fragment != checkLocationFragment && checkLocationFragment.isAdded) hide(checkLocationFragment) + if (fragment.isAdded) { + show(fragment) + } else { + add(R.id.fcv_security, fragment) + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/SecurityViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/SecurityViewModel.kt new file mode 100644 index 000000000..ecc81f16a --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/SecurityViewModel.kt @@ -0,0 +1,35 @@ +package com.lighthouse.presentation.ui.security + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.usecase.setting.SaveSecurityOptionUseCase +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.security.event.SecurityDirections +import com.lighthouse.presentation.ui.setting.SecurityOption +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SecurityViewModel @Inject constructor( + private val saveSecurityOptionUseCase: SaveSecurityOptionUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + val directionsFlow = MutableSharedFlow() + val isRevise = savedStateHandle.get(Extras.KEY_PIN_REVISE) ?: false + + fun gotoOtherScreen(directions: SecurityDirections) { + viewModelScope.launch { + directionsFlow.emit(directions) + } + } + + fun setSecurityOption(securityOption: SecurityOption) { + viewModelScope.launch { + saveSecurityOptionUseCase(securityOption.ordinal) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/event/SecurityDirections.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/event/SecurityDirections.kt new file mode 100644 index 000000000..3695778a9 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/event/SecurityDirections.kt @@ -0,0 +1,8 @@ +package com.lighthouse.presentation.ui.security.event + +enum class SecurityDirections { + PIN, + FINGERPRINT, + LOCATION, + MAIN +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/fingerprint/FingerprintFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/fingerprint/FingerprintFragment.kt new file mode 100644 index 000000000..60d36c1b0 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/fingerprint/FingerprintFragment.kt @@ -0,0 +1,60 @@ +package com.lighthouse.presentation.ui.security.fingerprint + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentFingerprintBinding +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.security.AuthCallback +import com.lighthouse.presentation.ui.security.SecurityViewModel +import com.lighthouse.presentation.ui.security.event.SecurityDirections +import com.lighthouse.presentation.ui.security.fingerprint.biometric.BiometricAuth +import com.lighthouse.presentation.ui.setting.SecurityOption + +class FingerprintFragment : Fragment(R.layout.fragment_fingerprint), AuthCallback { + + private val activityViewModel: SecurityViewModel by activityViewModels() + private lateinit var biometricAuth: BiometricAuth + private val binding: FragmentFingerprintBinding by viewBindings() + + private val activityLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> onAuthSuccess() + else -> onAuthError() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + biometricAuth = + BiometricAuth(requireActivity(), activityLauncher, this) + + binding.btnUseFingerprint.setOnClickListener { + biometricAuth.authenticate() + } + + binding.btnNotUseFingerprint.setOnClickListener { + activityViewModel.gotoOtherScreen(SecurityDirections.LOCATION) + } + } + + override fun onAuthSuccess() { + activityViewModel.setSecurityOption(SecurityOption.FINGERPRINT) + activityViewModel.gotoOtherScreen(SecurityDirections.LOCATION) + } + + override fun onAuthCancel() { + activityViewModel.setSecurityOption(SecurityOption.PIN) + } + + override fun onAuthError(@StringRes stringId: Int?) { + activityViewModel.setSecurityOption(SecurityOption.PIN) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/fingerprint/biometric/BiometricAuth.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/fingerprint/biometric/BiometricAuth.kt new file mode 100644 index 000000000..a87a95fa1 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/fingerprint/biometric/BiometricAuth.kt @@ -0,0 +1,78 @@ +package com.lighthouse.presentation.ui.security.fingerprint.biometric + +import android.content.Intent +import android.os.Build +import android.provider.Settings +import androidx.activity.result.ActivityResultLauncher +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import com.lighthouse.presentation.R +import com.lighthouse.presentation.ui.security.AuthCallback + +class BiometricAuth( + private val activity: FragmentActivity, + private val biometricLauncher: ActivityResultLauncher, + private val authCallback: AuthCallback +) { + + private val promptInfo: BiometricPrompt.PromptInfo by lazy { + BiometricPrompt.PromptInfo.Builder().apply { + setTitle(activity.getString(R.string.fingerprint_authentication)) + setDescription(activity.getString(R.string.fingerprint_description)) + setNegativeButtonText(activity.getString(R.string.all_cancel)) + }.build() + } + + private val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + authCallback.onAuthSuccess() + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + when (errorCode) { + BiometricPrompt.ERROR_NO_BIOMETRICS -> goBiometricSetting() + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> authCallback.onAuthCancel() + else -> authCallback.onAuthError(R.string.fingerprint_unknown_error) + } + } + } + + private val biometricPrompt: BiometricPrompt = BiometricPrompt(activity, authenticationCallback) + + private fun goBiometricSetting() { + val enrollIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Intent(Settings.ACTION_BIOMETRIC_ENROLL).apply { + putExtra( + Settings.EXTRA_BIOMETRIC_AUTHENTICATORS_ALLOWED, + BiometricManager.Authenticators.BIOMETRIC_STRONG + ) + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Intent(Settings.ACTION_FINGERPRINT_ENROLL) + } else { + Intent(Settings.ACTION_SECURITY_SETTINGS) + } + biometricLauncher.launch(enrollIntent) + } + + fun authenticate() { + val biometricManager = BiometricManager.from(activity.applicationContext) + val biometricAvailable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL) + } else { + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) + } + + when (biometricAvailable) { + BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo) + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> goBiometricSetting() + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> authCallback.onAuthError(R.string.fingerprint_error_no_hardware) + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> authCallback.onAuthError(R.string.fingerprint_error_no_hardware) + BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> authCallback.onAuthError(R.string.fingerprint_unavailable) + else -> authCallback.onAuthError(R.string.fingerprint_unknown_error) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinDialog.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinDialog.kt new file mode 100644 index 000000000..022c146ad --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinDialog.kt @@ -0,0 +1,100 @@ +package com.lighthouse.presentation.ui.security.pin + +import android.animation.ValueAnimator +import android.content.res.Configuration +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import androidx.fragment.app.viewModels +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentPinBinding +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extension.screenHeight +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.security.AuthCallback +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay + +@AndroidEntryPoint +class PinDialog(private val authCallback: AuthCallback) : BottomSheetDialogFragment(R.layout.fragment_pin) { + + private val binding: FragmentPinBinding by viewBindings() + private val viewModel: PinDialogViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + + viewLifecycleOwner.repeatOnStarted { + viewModel.pushedNum.collect { num -> + animateNumberPadBackground(num) + } + } + + initBottomSheetDialog(view) + managePinMode() + } + + private fun managePinMode() { + viewLifecycleOwner.repeatOnStarted { + viewModel.pinMode.collect { mode -> + when (mode) { + PinSettingType.CONFIRM -> binding.tvPinDescription.text = getString(R.string.pin_input_description) + PinSettingType.WRONG -> binding.tvPinDescription.text = getString(R.string.pin_wrong_description) + PinSettingType.COMPLETE -> { + authCallback.onAuthSuccess() + delay(1000L) + dismiss() + } + else -> {} + } + } + } + } + + private fun initBottomSheetDialog(view: View) { + val bottomSheetBehavior = BottomSheetBehavior.from(view.parent as View) + bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + binding.clPin.minHeight = (screenHeight * 0.9).toInt() + } + + private fun animateNumberPadBackground(num: Int) { + if (num < 0) return + + val view = when (num) { + 0 -> binding.tvNum0 + 1 -> binding.tvNum1 + 2 -> binding.tvNum2 + 3 -> binding.tvNum3 + 4 -> binding.tvNum4 + 5 -> binding.tvNum5 + 6 -> binding.tvNum6 + 7 -> binding.tvNum7 + 8 -> binding.tvNum8 + 9 -> binding.tvNum9 + else -> binding.ivBackspace + } + + val startColor = + when (requireContext().resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK)) { + Configuration.UI_MODE_NIGHT_YES -> requireContext().getColor(R.color.gray_500) + else -> requireContext().getColor(R.color.gray_200) + } + + val endColor = TypedValue().let { + requireContext().theme.resolveAttribute(android.R.attr.background, it, true) + it.data + } + + ValueAnimator.ofArgb(startColor, endColor) + .apply { + duration = 300 + addUpdateListener { + view.setBackgroundColor(it.animatedValue as Int) + } + }.start() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinDialogViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinDialogViewModel.kt new file mode 100644 index 000000000..a30893199 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinDialogViewModel.kt @@ -0,0 +1,35 @@ +package com.lighthouse.presentation.ui.security.pin + +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.usecase.setting.GetCorrespondWithPinUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PinDialogViewModel @Inject constructor( + private val getCorrespondWithPinUseCase: GetCorrespondWithPinUseCase +) : PinViewModel() { + + init { + _pinMode.value = PinSettingType.CONFIRM + _pinString.value = "" + } + + override fun goPreviousStep() { + } + + override fun goNextStep() { + viewModelScope.launch { + if (getCorrespondWithPinUseCase(pinString.value)) { + _pinMode.value = PinSettingType.COMPLETE + } else { + _pinMode.value = PinSettingType.WRONG + } + delay(1000L) + _pinString.value = "" + _pinMode.value = PinSettingType.CONFIRM + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinFragment.kt new file mode 100644 index 000000000..b393d8b86 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinFragment.kt @@ -0,0 +1,129 @@ +package com.lighthouse.presentation.ui.security.pin + +import android.animation.ValueAnimator +import android.app.Activity +import android.content.res.Configuration +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentPinBinding +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.security.SecurityViewModel +import com.lighthouse.presentation.ui.security.event.SecurityDirections +import com.lighthouse.presentation.ui.setting.SecurityOption +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay + +@AndroidEntryPoint +class PinFragment : Fragment(R.layout.fragment_pin) { + + private val binding: FragmentPinBinding by viewBindings() + private val viewModel: PinSettingViewModel by viewModels() + private val activityViewModel: SecurityViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + binding.lifecycleOwner = this + binding.tvSecureNotUse.visibility = if (activityViewModel.isRevise) View.INVISIBLE else View.VISIBLE + + managePinMode() + + viewLifecycleOwner.repeatOnStarted { + viewModel.pushedNum.collect { num -> + animateNumberPadBackground(num) + } + } + + binding.tvSecureNotUse.setOnClickListener { + activityViewModel.setSecurityOption(SecurityOption.NONE) + activityViewModel.gotoOtherScreen(SecurityDirections.LOCATION) + } + } + + private fun managePinMode() { + viewLifecycleOwner.repeatOnStarted { + viewModel.pinMode.collect { + when (it) { + PinSettingType.INITIAL -> { + binding.tvPinDescription.text = getString(R.string.pin_initial_description) + binding.btnPinPrev.visibility = View.GONE + } + PinSettingType.CONFIRM -> { + binding.tvPinDescription.text = getString(R.string.pin_confirm_description) + binding.btnPinPrev.visibility = View.VISIBLE + } + PinSettingType.WRONG -> { + binding.tvPinDescription.text = getString(R.string.pin_wrong_description) + } + PinSettingType.COMPLETE -> { + Snackbar.make(requireView(), getString(R.string.pin_complete), Snackbar.LENGTH_SHORT) + .setAnimationMode(Snackbar.ANIMATION_MODE_SLIDE) + .show() + delay(1000L) + if (activityViewModel.isRevise) { + requireActivity().apply { + setResult(Activity.RESULT_OK) + finish() + } + } else { + activityViewModel.setSecurityOption(SecurityOption.PIN) + activityViewModel.gotoOtherScreen(SecurityDirections.FINGERPRINT) + } + } + PinSettingType.ERROR -> { + Snackbar.make(requireView(), getString(R.string.pin_internal_error), Snackbar.LENGTH_SHORT) + .setAnimationMode(Snackbar.ANIMATION_MODE_SLIDE) + .show() + delay(500L) + activityViewModel.gotoOtherScreen(SecurityDirections.LOCATION) + } + PinSettingType.WAIT -> {} + } + } + } + } + + private fun animateNumberPadBackground(num: Int) { + if (num < 0) return + + val view = when (num) { + 0 -> binding.tvNum0 + 1 -> binding.tvNum1 + 2 -> binding.tvNum2 + 3 -> binding.tvNum3 + 4 -> binding.tvNum4 + 5 -> binding.tvNum5 + 6 -> binding.tvNum6 + 7 -> binding.tvNum7 + 8 -> binding.tvNum8 + 9 -> binding.tvNum9 + else -> binding.ivBackspace + } + + val startColor = + when (requireContext().resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK)) { + Configuration.UI_MODE_NIGHT_YES -> requireContext().getColor(R.color.gray_500) + else -> requireContext().getColor(R.color.gray_200) + } + + val endColor = TypedValue().let { + requireContext().theme.resolveAttribute(android.R.attr.background, it, true) + it.data + } + + ValueAnimator.ofArgb(startColor, endColor) + .apply { + duration = 300 + addUpdateListener { + view.setBackgroundColor(it.animatedValue as Int) + } + }.start() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinSettingType.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinSettingType.kt new file mode 100644 index 000000000..629f3e1f4 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinSettingType.kt @@ -0,0 +1,10 @@ +package com.lighthouse.presentation.ui.security.pin + +enum class PinSettingType { + INITIAL, + CONFIRM, + WRONG, + COMPLETE, + ERROR, + WAIT +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinSettingViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinSettingViewModel.kt new file mode 100644 index 000000000..9dc6162ea --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinSettingViewModel.kt @@ -0,0 +1,55 @@ +package com.lighthouse.presentation.ui.security.pin + +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.usecase.setting.SavePinUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class PinSettingViewModel @Inject constructor( + private val savePinUseCase: SavePinUseCase +) : PinViewModel() { + + private var temporaryPinString: String? = null + + override fun goPreviousStep() { + _pinString.value = "" + _pinMode.value = PinSettingType.INITIAL + } + + override fun goNextStep() { + viewModelScope.launch { + when (pinMode.value) { + PinSettingType.INITIAL -> { + temporaryPinString = pinString.value + _pinMode.value = PinSettingType.WAIT + delay(500L) + _pinMode.value = PinSettingType.CONFIRM + _pinString.value = "" + } + else -> { + if (pinString.value == temporaryPinString) { + savePin() + } else { + _pinMode.value = PinSettingType.WRONG + delay(1000L) + _pinString.value = "" + _pinMode.value = PinSettingType.CONFIRM + } + } + } + } + } + + private fun savePin() { + viewModelScope.launch { + savePinUseCase(pinString.value).onSuccess { + _pinMode.value = PinSettingType.COMPLETE + }.onFailure { + _pinMode.value = PinSettingType.ERROR + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinViewModel.kt new file mode 100644 index 000000000..eb5eac0a5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/security/pin/PinViewModel.kt @@ -0,0 +1,47 @@ +package com.lighthouse.presentation.ui.security.pin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +abstract class PinViewModel : ViewModel() { + + protected val _pinString = MutableStateFlow("") + val pinString = _pinString.asStateFlow() + + protected val _pinMode = MutableStateFlow(PinSettingType.INITIAL) + val pinMode = _pinMode.asStateFlow() + + private val _pushedNum = MutableSharedFlow() + val pushedNum = _pushedNum.asSharedFlow() + + fun inputPin(num: Int) { + viewModelScope.launch { _pushedNum.emit(num) } + + if (pinMode.value == PinSettingType.WAIT || pinMode.value == PinSettingType.WRONG) return + + if (pinString.value.length < 6) { + _pinString.value = "${pinString.value}$num" + } + + if (pinString.value.length == 6) { + goNextStep() + } + } + + fun removePin() { + viewModelScope.launch { _pushedNum.emit(10) } + + if (pinString.value.isNotEmpty()) { + _pinString.value = pinString.value.dropLast(1) + } + } + + abstract fun goPreviousStep() + + abstract fun goNextStep() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SecurityOption.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SecurityOption.kt new file mode 100644 index 000000000..a3933f7f4 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SecurityOption.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.ui.setting + +enum class SecurityOption(val text: String) { + NONE("사용 안 함"), + PIN("PIN"), + FINGERPRINT("지문") +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingFragment.kt new file mode 100644 index 000000000..d377d87ae --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingFragment.kt @@ -0,0 +1,48 @@ +package com.lighthouse.presentation.ui.setting + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentSettingBinding +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.main.MainViewModel +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SettingFragment : Fragment(R.layout.fragment_setting) { + + private val binding: FragmentSettingBinding by viewBindings() + private val viewModel: SettingViewModel by activityViewModels() + private val activityViewModel: MainViewModel by activityViewModels() + + private val settingMainFragment = SettingMainFragment() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + childFragmentManager.commit { + add(R.id.fcv_setting, settingMainFragment) + } + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + } + + fun isSettingMainFragment(): Boolean { + return if (childFragmentManager.fragments.size == 1) { + true + } else { + val detail = childFragmentManager.findFragmentByTag(Extras.TAG_DETAIL_SETTING) + if (detail != null) { + childFragmentManager.commit { + remove(detail) + } + activityViewModel.gotoMenuItem(R.id.menu_setting) + } + false + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingMainFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingMainFragment.kt new file mode 100644 index 000000000..00acb2944 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingMainFragment.kt @@ -0,0 +1,230 @@ +package com.lighthouse.presentation.ui.setting + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import com.lighthouse.presentation.R +import com.lighthouse.presentation.background.BeepWorkManager +import com.lighthouse.presentation.databinding.FragmentSettingMainBinding +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.dialog.ProgressDialog +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.main.MainViewModel +import com.lighthouse.presentation.ui.security.AuthCallback +import com.lighthouse.presentation.ui.security.AuthManager +import com.lighthouse.presentation.ui.signin.SignInActivity +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class SettingMainFragment : Fragment(R.layout.fragment_setting_main), AuthCallback { + + private val binding: FragmentSettingMainBinding by viewBindings() + private val viewModel: SettingViewModel by activityViewModels() + private val activityViewModel: MainViewModel by activityViewModels() + + private val settingSecurityFragment by lazy { SettingSecurityFragment() } + private val progressDialog by lazy { ProgressDialog() } + + private lateinit var googleSignInClient: GoogleSignInClient + private val auth: FirebaseAuth = Firebase.auth + private val activityLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + + try { + signInWithGoogle(task.getResult(ApiException::class.java)) + } catch (e: ApiException) { + Snackbar.make(requireView(), getString(R.string.signin_google_fail), Snackbar.LENGTH_SHORT).show() + } + } else { + Snackbar.make(requireView(), getString(R.string.signin_google_connect_fail), Snackbar.LENGTH_SHORT) + .show() + } + } + + @Inject + lateinit var authManager: AuthManager + private val biometricLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> authenticate() + else -> onAuthError() + } + } + + private val locationLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + locationPermissionCheck() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + OssLicensesMenuActivity.setActivityTitle(getString(R.string.setting_open_source_license)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + + binding.tvUsedGifticon.setOnClickListener { gotoUsedGifticon() } + binding.tvSecurity.setOnClickListener { authenticate() } + binding.tvSignOut.setOnClickListener { signOut() } + binding.tvWithdrawal.setOnClickListener { withdrawal() } + binding.tvLocation.setOnClickListener { gotoPermissionSetting() } + binding.smNotification.setOnCheckedChangeListener { _, isChecked -> + val workManager = BeepWorkManager(requireContext()) + viewModel.saveNotificationOption(isChecked) + when (isChecked) { + true -> workManager.notificationEnqueue() + false -> workManager.notificationCancel() + } + } + binding.tvOpenSourceLicense.setOnClickListener { + startActivity( + Intent(requireContext(), OssLicensesMenuActivity::class.java) + ) + } + + if (viewModel.userPreferenceState.value.guest) { + initGoogleLogin() + } + + locationPermissionCheck() + } + + private fun gotoSecuritySetting() { + activityViewModel.gotoMenuItem(-1) + parentFragmentManager.commit { + setCustomAnimations(R.anim.anim_slide_in_bottom, R.anim.anim_slide_out_top) + add(R.id.fcv_setting, settingSecurityFragment, Extras.TAG_DETAIL_SETTING) + } + } + + private fun gotoUsedGifticon() { + activityViewModel.gotoMenuItem(-1) + parentFragmentManager.commit { + setCustomAnimations(R.anim.anim_slide_in_bottom, R.anim.anim_slide_out_top) + add(R.id.fcv_setting, UsedGifticonFragment(), Extras.TAG_DETAIL_SETTING) + } + } + + private fun authenticate() { + authManager.auth(requireActivity(), biometricLauncher, this) + } + + private fun signOut() { + Firebase.auth.signOut() + val intent = Intent(requireContext(), SignInActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + } + + private fun withdrawal() { + Firebase.auth.currentUser?.delete() + viewModel.removeUserData() + signOut() + } + + private fun initGoogleLogin() { + val googleSignInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestIdToken(getString(R.string.default_web_client_id)) + .build() + + googleSignInClient = GoogleSignIn.getClient(requireContext(), googleSignInOptions) + + binding.tvSignIn.setOnClickListener { + activityLauncher.launch(googleSignInClient.signInIntent) + } + } + + private fun signInWithGoogle(account: GoogleSignInAccount) { + progressDialog.show(childFragmentManager, "progress") + account.email?.let { email -> + auth.fetchSignInMethodsForEmail(email).addOnCompleteListener { task -> + progressDialog.dismiss() + val isInitial = task.result.signInMethods?.size == 0 + if (isInitial) { + val credential = GoogleAuthProvider.getCredential(account.idToken, null) + auth.signInWithCredential(credential).addOnCompleteListener { signInTask -> + if (signInTask.isSuccessful) { + Snackbar.make(requireView(), getString(R.string.signin_success), Snackbar.LENGTH_SHORT) + .show() + auth.uid?.let { viewModel.moveGuestData(it) } + } else { + Snackbar.make(requireView(), getString(R.string.signin_fail), Snackbar.LENGTH_SHORT).show() + } + } + } else { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.setting_already_exist_email) + .show() + } + } + } + } + + override fun onAuthSuccess() { + gotoSecuritySetting() + } + + override fun onAuthCancel() { + } + + override fun onAuthError(stringId: Int?) { + stringId?.let { + Snackbar.make(requireView(), getString(it), Snackbar.LENGTH_SHORT).show() + } + } + + private fun locationPermissionCheck() { + for (permission in PERMISSIONS) { + val result: Int = ContextCompat.checkSelfPermission(requireContext(), permission) + if (PackageManager.PERMISSION_GRANTED != result) { + binding.tvLocationOption.text = getString(R.string.location_not_allowed) + return + } + } + binding.tvLocationOption.text = getString(R.string.location_allowed) + } + + private fun gotoPermissionSetting() { + val intent = Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.fromParts("package", requireActivity().packageName, null) + } + locationLauncher.launch(intent) + } + + companion object { + private val PERMISSIONS = + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingSecurityFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingSecurityFragment.kt new file mode 100644 index 000000000..6ba2915f3 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingSecurityFragment.kt @@ -0,0 +1,158 @@ +package com.lighthouse.presentation.ui.setting + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentSecuritySettingBinding +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.main.MainViewModel +import com.lighthouse.presentation.ui.security.AuthCallback +import com.lighthouse.presentation.ui.security.AuthManager +import com.lighthouse.presentation.ui.security.SecurityActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@AndroidEntryPoint +class SettingSecurityFragment : Fragment(R.layout.fragment_security_setting), AuthCallback { + + private val binding: FragmentSecuritySettingBinding by viewBindings() + private val activityViewModel: MainViewModel by activityViewModels() + private val viewModel: SettingViewModel by activityViewModels() + + private val securityOptionItems = + arrayOf(SecurityOption.NONE.text, SecurityOption.PIN.text, SecurityOption.FINGERPRINT.text) + private var checkedSecurityOption: Int = 0 + private lateinit var currentSecurityOption: StateFlow + private lateinit var callback: OnBackPressedCallback + + @Inject + lateinit var authManager: AuthManager + private lateinit var optionChangeLauncher: ActivityResultLauncher + private lateinit var pinChangeLauncher: ActivityResultLauncher + private lateinit var biometricLauncher: ActivityResultLauncher + + override fun onAttach(context: Context) { + super.onAttach(context) + callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityViewModel.gotoMenuItem(R.id.menu_setting) + + parentFragmentManager.commit { + remove(this@SettingSecurityFragment) + } + } + } + requireActivity().onBackPressedDispatcher.addCallback(this, callback) + + optionChangeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + Snackbar.make(requireView(), getString(R.string.security_setting_change_success), Snackbar.LENGTH_SHORT) + .show() + viewModel.saveSecurityOption(checkedSecurityOption) + } else { + Snackbar.make(requireView(), getString(R.string.security_setting_change_failure), Snackbar.LENGTH_SHORT) + .show() + } + } + + pinChangeLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + Snackbar.make(requireView(), getString(R.string.security_setting_change_success), Snackbar.LENGTH_SHORT) + .show() + } else { + Snackbar.make(requireView(), getString(R.string.security_setting_change_failure), Snackbar.LENGTH_SHORT) + .show() + } + } + + biometricLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> authenticate() + else -> onAuthError() + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.vm = viewModel + binding.lifecycleOwner = viewLifecycleOwner + checkedSecurityOption = viewModel.securityOption.value.ordinal + currentSecurityOption = viewModel.securityOption + + binding.tvChangeSecurityOption.setOnClickListener { + // 옵션 변경 시 currentSecurityOption 바뀌기 때문에 매번 AlertDialog 을 만들어 사용합니다. + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.security_setting_option_change) + .setNeutralButton(R.string.confirmation_cancel) { dialog, _ -> + dialog.dismiss() + } + .setPositiveButton(getString(R.string.confirmation_ok)) { dialog, which -> + if (checkedSecurityOption != currentSecurityOption.value.ordinal) { + when (checkedSecurityOption) { + SecurityOption.NONE.ordinal -> { // 사용 안 함 + viewModel.saveSecurityOption(SecurityOption.NONE.ordinal) + } + SecurityOption.PIN.ordinal -> { + if (currentSecurityOption.value.ordinal == SecurityOption.NONE.ordinal) { // 사용 안 함 -> PIN + val intent = Intent(requireContext(), SecurityActivity::class.java) + intent.putExtra(Extras.KEY_PIN_REVISE, true) + optionChangeLauncher.launch(intent) + } else { // 지문 -> PIN은 이미 PIN이 설정되어 있기에 사용옵션만 저장 + viewModel.saveSecurityOption(SecurityOption.PIN.ordinal) + } + } + SecurityOption.FINGERPRINT.ordinal -> { // 사용 안 함 or PIN -> 지문 + authenticate() + } + } + } + dialog.dismiss() + }.setSingleChoiceItems(securityOptionItems, currentSecurityOption.value.ordinal) { dialog, which -> + checkedSecurityOption = which + }.show() + } + + binding.tvChangePin.setOnClickListener { + val intent = Intent(requireContext(), SecurityActivity::class.java) + intent.putExtra(Extras.KEY_PIN_REVISE, true) + pinChangeLauncher.launch(intent) + } + } + + private fun authenticate() { + authManager.authFingerprint(requireActivity(), biometricLauncher, this) + } + + override fun onDetach() { + super.onDetach() + callback.remove() + } + + override fun onAuthSuccess() { + Snackbar.make(requireView(), getString(R.string.security_setting_change_success), Snackbar.LENGTH_SHORT).show() + viewModel.saveSecurityOption(SecurityOption.FINGERPRINT.ordinal) + } + + override fun onAuthCancel() { + Snackbar.make(requireView(), getString(R.string.security_setting_change_cancel), Snackbar.LENGTH_SHORT).show() + } + + override fun onAuthError(stringId: Int?) { + Snackbar.make(requireView(), getString(R.string.security_setting_change_failure), Snackbar.LENGTH_SHORT).show() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingViewModel.kt new file mode 100644 index 000000000..453138cff --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/SettingViewModel.kt @@ -0,0 +1,82 @@ +package com.lighthouse.presentation.ui.setting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.usecase.MoveUserIdGifticonUseCase +import com.lighthouse.domain.usecase.setting.GetGuestOptionUseCase +import com.lighthouse.domain.usecase.setting.GetNotificationOptionUseCase +import com.lighthouse.domain.usecase.setting.GetSecurityOptionUseCase +import com.lighthouse.domain.usecase.setting.MoveGuestDataUseCase +import com.lighthouse.domain.usecase.setting.RemoveUserDataUseCase +import com.lighthouse.domain.usecase.setting.SaveGuestOptionUseCase +import com.lighthouse.domain.usecase.setting.SaveNotificationOptionUseCase +import com.lighthouse.domain.usecase.setting.SaveSecurityOptionUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingViewModel @Inject constructor( + getSecurityOptionUseCase: GetSecurityOptionUseCase, + private val saveSecurityOptionUseCase: SaveSecurityOptionUseCase, + getGuestOptionUseCase: GetGuestOptionUseCase, + private val saveGuestOptionUseCase: SaveGuestOptionUseCase, + getNotificationOptionUseCase: GetNotificationOptionUseCase, + private val saveNotificationOptionUseCase: SaveNotificationOptionUseCase, + private val moveGuestDataUseCase: MoveGuestDataUseCase, + private val moveUserIdGifticonUseCase: MoveUserIdGifticonUseCase, + private val removeUserDataUseCase: RemoveUserDataUseCase +) : ViewModel() { + + val securityOption: StateFlow = + getSecurityOptionUseCase().map { + SecurityOption.values()[it] + }.stateIn(viewModelScope, SharingStarted.Eagerly, SecurityOption.NONE) + + private val guestOption: StateFlow by lazy { + getGuestOptionUseCase().stateIn(viewModelScope, SharingStarted.Eagerly, false) + } + + private val notificationOption: StateFlow by lazy { + getNotificationOptionUseCase().stateIn(viewModelScope, SharingStarted.Eagerly, true) + } + + val userPreferenceState: StateFlow = combine( + guestOption, + securityOption, + notificationOption + ) { guest, security, notification -> + UserPreferenceState(guest, security, notification) + }.stateIn(viewModelScope, SharingStarted.Eagerly, UserPreferenceState()) + + fun saveSecurityOption(selectedOption: Int) { + viewModelScope.launch { + saveSecurityOptionUseCase(selectedOption) + } + } + + fun moveGuestData(uid: String) { + viewModelScope.launch { + saveGuestOptionUseCase(false) + moveGuestDataUseCase(uid) + moveUserIdGifticonUseCase("Guest", uid) + } + } + + fun saveNotificationOption(selectedOption: Boolean) { + viewModelScope.launch { + saveNotificationOptionUseCase(selectedOption) + } + } + + fun removeUserData() { + viewModelScope.launch { + removeUserDataUseCase() + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UsedGifticonFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UsedGifticonFragment.kt new file mode 100644 index 000000000..5e813b7e3 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UsedGifticonFragment.kt @@ -0,0 +1,74 @@ +package com.lighthouse.presentation.ui.setting + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.commit +import androidx.fragment.app.viewModels +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentUsedGifticonBinding +import com.lighthouse.presentation.extension.dp +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.GifticonViewHolderType +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.detailgifticon.GifticonDetailActivity +import com.lighthouse.presentation.ui.main.MainViewModel +import com.lighthouse.presentation.ui.map.adapter.GifticonAdapter +import com.lighthouse.presentation.util.recycler.GridSpaceItemDecoration +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class UsedGifticonFragment : Fragment(R.layout.fragment_used_gifticon) { + + private val binding: FragmentUsedGifticonBinding by viewBindings() + private val viewModel: UsedGifticonViewModel by viewModels() + private val activityViewModel: MainViewModel by activityViewModels() + + private val itemDecoration = GridSpaceItemDecoration(8.dp, 8.dp) + + private lateinit var callback: OnBackPressedCallback + + override fun onAttach(context: Context) { + super.onAttach(context) + callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityViewModel.gotoMenuItem(R.id.menu_setting) + + parentFragmentManager.commit { + remove(this@UsedGifticonFragment) + } + } + } + requireActivity().onBackPressedDispatcher.addCallback(this, callback) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.lifecycleOwner = viewLifecycleOwner + binding.vm = viewModel + + with(binding.rvUsedGifticon) { + adapter = GifticonAdapter(GifticonViewHolderType.VERTICAL) { gifticon -> + gotoGifticonDetail(gifticon.id) + } + addItemDecoration(itemDecoration) + } + } + + private fun gotoGifticonDetail(id: String) { + startActivity( + Intent(requireContext(), GifticonDetailActivity::class.java).apply { + putExtra(Extras.KEY_GIFTICON_ID, id) + } + ) + } + + override fun onDetach() { + super.onDetach() + callback.remove() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UsedGifticonViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UsedGifticonViewModel.kt new file mode 100644 index 000000000..eaa1b0c09 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UsedGifticonViewModel.kt @@ -0,0 +1,28 @@ +package com.lighthouse.presentation.ui.setting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.usecase.GetGifticonsUseCase +import com.lighthouse.presentation.mapper.toPresentation +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class UsedGifticonViewModel @Inject constructor( + getGifticonsUseCase: GetGifticonsUseCase +) : ViewModel() { + + val usedGifticons = getGifticonsUseCase.getUsedGifticons().map { dbResult -> + if (dbResult is DbResult.Success) { + dbResult.data.map { + it.toPresentation() + } + } else { + emptyList() + } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UserPreferenceState.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UserPreferenceState.kt new file mode 100644 index 000000000..6d7a197b2 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/setting/UserPreferenceState.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.ui.setting + +data class UserPreferenceState( + val guest: Boolean = true, + val security: SecurityOption = SecurityOption.NONE, + val notification: Boolean = true +) diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInActivity.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInActivity.kt new file mode 100644 index 000000000..e4b055870 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInActivity.kt @@ -0,0 +1,27 @@ +package com.lighthouse.presentation.ui.signin + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.commit +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.ActivitySignInBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class SignInActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySignInBinding + + private val signInFragment by lazy { SignInFragment() } + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_sign_in) + supportFragmentManager.commit { + add(R.id.fcv_signin, signInFragment) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInFragment.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInFragment.kt new file mode 100644 index 000000000..0fc192534 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInFragment.kt @@ -0,0 +1,145 @@ +package com.lighthouse.presentation.ui.signin + +import android.app.Activity +import android.content.Intent +import android.graphics.drawable.AnimatedVectorDrawable +import android.os.Bundle +import android.view.View +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.material.snackbar.Snackbar +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import com.lighthouse.presentation.R +import com.lighthouse.presentation.databinding.FragmentSignInBinding +import com.lighthouse.presentation.extension.repeatOnStarted +import com.lighthouse.presentation.extra.Extras +import com.lighthouse.presentation.ui.common.dialog.ProgressDialog +import com.lighthouse.presentation.ui.common.viewBindings +import com.lighthouse.presentation.ui.main.MainActivity +import com.lighthouse.presentation.ui.security.SecurityActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.first + +@AndroidEntryPoint +class SignInFragment : Fragment(R.layout.fragment_sign_in) { + + private val binding: FragmentSignInBinding by viewBindings() + private val viewModel: SignInViewModel by viewModels() + + private val progressDialog by lazy { ProgressDialog() } + + private lateinit var googleSignInClient: GoogleSignInClient + private val auth: FirebaseAuth = Firebase.auth + private val activityLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + progressDialog.dismiss() + if (result.resultCode == Activity.RESULT_OK) { + val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + signInWithGoogle(task.getResult(ApiException::class.java)) + } catch (e: ApiException) { + Snackbar.make(requireView(), getString(R.string.signin_google_fail), Snackbar.LENGTH_SHORT).show() + } + } else { + Snackbar.make(requireView(), getString(R.string.signin_google_connect_fail), Snackbar.LENGTH_SHORT) + .show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + checkAutoLogin() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val animatedVectorDrawable = binding.ivLogo.drawable as AnimatedVectorDrawable + animatedVectorDrawable.start() + + binding.tvGuestSignin.setOnClickListener { + guestSignIn() + } + + binding.btnGoogleLogin.setOnClickListener { + activityLauncher.launch(googleSignInClient.signInIntent) + progressDialog.show(parentFragmentManager, "progress") + } + } + + private fun checkAutoLogin() { + repeatOnStarted { + if (viewModel.isGuestStored.first()) { + if (viewModel.isGuest.first()) { + gotoMain() + } + } + } + + if (auth.currentUser == null) { + initGoogleLogin() + } else { + gotoMain() + } + } + + private fun initGoogleLogin() { + val googleSignInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestIdToken(getString(R.string.default_web_client_id)) + .build() + + googleSignInClient = GoogleSignIn.getClient(requireContext(), googleSignInOptions) + } + + private fun signInWithGoogle(account: GoogleSignInAccount) { + account.email?.let { email -> + auth.fetchSignInMethodsForEmail(email).addOnCompleteListener { task -> + val isInitial = task.result.signInMethods?.size == 0 + val credential = GoogleAuthProvider.getCredential(account.idToken, null) + auth.signInWithCredential(credential).addOnCompleteListener { signInTask -> + progressDialog.dismiss() + if (signInTask.isSuccessful) { + Snackbar.make(requireView(), getString(R.string.signin_success), Snackbar.LENGTH_SHORT).show() + viewModel.saveGuestOption(false) + if (isInitial) { + gotoSecurity() + } else { + gotoMain() + } + } else { + Snackbar.make(requireView(), getString(R.string.signin_fail), Snackbar.LENGTH_SHORT).show() + } + } + } + } + } + + private fun gotoMain() { + val intent = Intent(requireContext(), MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + startActivity(intent) + } + + private fun gotoSecurity() { + val intent = Intent(requireContext(), SecurityActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra(Extras.KEY_PIN_REVISE, false) + } + startActivity(intent) + } + + private fun guestSignIn() { + viewModel.saveGuestOption(true) + gotoSecurity() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInViewModel.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInViewModel.kt new file mode 100644 index 000000000..8f6afbde1 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/signin/SignInViewModel.kt @@ -0,0 +1,28 @@ +package com.lighthouse.presentation.ui.signin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lighthouse.domain.model.UserPreferenceOption +import com.lighthouse.domain.usecase.setting.GetGuestOptionUseCase +import com.lighthouse.domain.usecase.setting.GetOptionStoredUseCase +import com.lighthouse.domain.usecase.setting.SaveGuestOptionUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignInViewModel @Inject constructor( + getOptionStoredUseCase: GetOptionStoredUseCase, + getGuestOptionUseCase: GetGuestOptionUseCase, + private val saveGuestOptionUseCase: SaveGuestOptionUseCase +) : ViewModel() { + + val isGuestStored = getOptionStoredUseCase(UserPreferenceOption.GUEST) + val isGuest = getGuestOptionUseCase() + + fun saveGuestOption(option: Boolean) { + viewModelScope.launch { + saveGuestOptionUseCase(option) + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidget.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidget.kt new file mode 100644 index 000000000..f24b42ab7 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidget.kt @@ -0,0 +1,261 @@ +package com.lighthouse.presentation.ui.widget + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.CircularProgressIndicator +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.unit.ColorProvider +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import com.lighthouse.presentation.R +import com.lighthouse.presentation.background.BeepWorkManager +import com.lighthouse.presentation.extra.Extras.CATEGORY_ACCOMMODATION +import com.lighthouse.presentation.extra.Extras.CATEGORY_CAFE +import com.lighthouse.presentation.extra.Extras.CATEGORY_CONVENIENCE +import com.lighthouse.presentation.extra.Extras.CATEGORY_CULTURE +import com.lighthouse.presentation.extra.Extras.CATEGORY_MART +import com.lighthouse.presentation.extra.Extras.CATEGORY_RESTAURANT +import com.lighthouse.presentation.extra.Extras.KEY_WIDGET_BRAND +import com.lighthouse.presentation.extra.Extras.KEY_WIDGET_EVENT +import com.lighthouse.presentation.extra.Extras.WIDGET_EVENT_MAP +import com.lighthouse.presentation.ui.main.MainActivity +import com.lighthouse.presentation.ui.widget.component.AppWidgetColumn + +class BeepWidget : GlanceAppWidget() { + + override val stateDefinition = BeepWidgetInfoStateDefinition + + override val sizeMode: SizeMode + get() = SizeMode.Responsive(setOf(thinMode, smallMode, mediumMode, largeMode)) + + @Composable + override fun Content() { + val widgetState = currentState() + val size = LocalSize.current + AppWidgetColumn( + verticalAlignment = Alignment.Top, + horizonAlignment = Alignment.Start + ) { + WidgetHead() + when (widgetState) { + is WidgetState.Loading -> WidgetLoading() + is WidgetState.Available -> { + when (size) { + thinMode -> WidgetBody(widgetState, false) + else -> WidgetBody(widgetState, true) + } + } + is WidgetState.Unavailable -> WidgetErrorBody(R.string.widget_common_error) + is WidgetState.Empty -> WidgetErrorBody(R.string.widget_empty_data) + is WidgetState.NoExistsLocationPermission -> WidgetErrorBody(R.string.widget_has_not_permission) + } + } + } + + companion object { + private val thinMode = DpSize(120.dp, 80.dp) + private val smallMode = DpSize(184.dp, 120.dp) + private val mediumMode = DpSize(260.dp, 200.dp) + private val largeMode = DpSize(260.dp, 280.dp) + } +} + +@Composable +fun WidgetLoading() { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } +} + +@Composable +fun WidgetHead() { + val intent = Intent(LocalContext.current, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + Row( + modifier = GlanceModifier.fillMaxWidth().background(R.color.beep_pink).height(40.dp).padding(start = 12.dp) + .clickable(actionStartActivity(intent)), + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = GlanceModifier.width(4.dp)) + + Image( + modifier = GlanceModifier.size(48.dp).padding(8.dp) + .clickable(onClick = actionRunCallback()), + provider = ImageProvider(resId = R.drawable.ic_splash_beep), + contentDescription = stringResource(id = R.string.widget_gifticon_image_description) + ) + + Spacer(GlanceModifier.defaultWeight()) + + Image( + modifier = GlanceModifier.size(52.dp).padding(start = 16.dp, end = 16.dp) + .clickable(onClick = actionRunCallback()), + provider = ImageProvider(resId = R.drawable.ic_widget_refresh_24), + contentDescription = stringResource(id = R.string.widget_gifticon_image_description) + ) + Spacer(GlanceModifier.width(12.dp)) + } +} + +@Composable +fun WidgetErrorBody(@StringRes textId: Int) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + stringResource(id = textId), + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = ColorProvider(day = Color.Black, night = Color.White) + ) + ) + } +} + +@Composable +fun WidgetBody(widgetState: WidgetState.Available, isShowGifticon: Boolean) { + Spacer(modifier = GlanceModifier.height(10.dp)) + Text( + modifier = GlanceModifier.fillMaxWidth(), + text = stringResource(id = R.string.widget_header_description), + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = ColorProvider(day = Color.Black, night = Color.White) + ), + maxLines = 1 + ) + LazyColumn(modifier = GlanceModifier.padding(12.dp)) { + items(widgetState.gifticons.toList()) { item -> + GifticonItem(item, isShowGifticon) + } + } +} + +@Composable +private fun GifticonItem( + item: Pair, Int?>, + isShowGifticon: Boolean, + modifier: GlanceModifier = GlanceModifier +) { + val brandWithCategory = item.first + val gifticonCount = item.second ?: return + + val intent = Intent(LocalContext.current, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + Box() { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth().padding(start = 24.dp, top = 10.dp, end = 24.dp, bottom = 10.dp) + ) { + Image( + modifier = GlanceModifier.size(16.dp), + provider = ImageProvider(resId = getDrawableId(brandWithCategory.value)), + contentDescription = stringResource(id = R.string.widget_gifticon_image_description) + ) + Spacer(modifier = GlanceModifier.width(8.dp)) + Text( + text = brandWithCategory.key, + style = TextStyle( + fontSize = 14.sp, + textAlign = TextAlign.Start, + color = ColorProvider(day = Color.Black, night = Color.White) + ) + ) + Spacer(GlanceModifier.defaultWeight()) + if (isShowGifticon) { + Text( + text = stringResource(R.string.widget_gifticon_count, gifticonCount), + style = TextStyle( + fontSize = 14.sp, + textAlign = TextAlign.End + ) + ) + } + } + Spacer( + modifier = modifier.fillMaxSize().clickable(onClick = startActivityHome(intent, brandWithCategory)) + ) + } +} + +@Composable +private fun startActivityHome( + intent: Intent, + brandWithCategory: Map.Entry +) = actionStartActivity( + intent, + parameters = actionParametersOf( + ActionParameters.Key(KEY_WIDGET_BRAND) to brandWithCategory.key, + ActionParameters.Key(KEY_WIDGET_EVENT) to WIDGET_EVENT_MAP + ) +) + +fun getDrawableId(category: String): Int { + return when (category) { + CATEGORY_MART -> R.drawable.ic_widget_market + CATEGORY_CONVENIENCE -> R.drawable.ic_widget_convenience + CATEGORY_CULTURE -> R.drawable.ic_widget_culture + CATEGORY_ACCOMMODATION -> R.drawable.ic_widget_accommodation + CATEGORY_RESTAURANT -> R.drawable.ic_widget_restaurant + CATEGORY_CAFE -> R.drawable.ic_widget_cafe + else -> R.drawable.ic_marker_base + } +} + +class WidgetRefreshAction : ActionCallback { + + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + BeepWorkManager(context).widgetEnqueue(true) + } +} + +@SuppressLint("ComposableNaming") +@Composable +fun stringResource(@StringRes id: Int, vararg args: Any) = LocalContext.current.getString(id, *args) diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetInfoStateDefinition.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetInfoStateDefinition.kt new file mode 100644 index 000000000..1ad66bc81 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetInfoStateDefinition.kt @@ -0,0 +1,47 @@ +package com.lighthouse.presentation.ui.widget + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import androidx.datastore.dataStoreFile +import androidx.glance.state.GlanceStateDefinition +import kotlinx.serialization.json.Json +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +object BeepWidgetInfoStateDefinition : GlanceStateDefinition { + + private const val DATA_STORE_FILENAME = "beep" + + private val Context.datastore by dataStore(DATA_STORE_FILENAME, GifticonInfoSerializer) + + override suspend fun getDataStore(context: Context, fileKey: String): DataStore { + return context.datastore + } + + override fun getLocation(context: Context, fileKey: String): File { + return context.dataStoreFile(DATA_STORE_FILENAME) + } + + object GifticonInfoSerializer : Serializer { + + override val defaultValue = WidgetState.Unavailable(message = "찾을 수 없습니다.") + + override suspend fun readFrom(input: InputStream): WidgetState { + return Json.decodeFromString( + WidgetState.serializer(), + input.readBytes().decodeToString() + ) + } + + override suspend fun writeTo(t: WidgetState, output: OutputStream) { + output.use { + it.write( + Json.encodeToString(WidgetState.serializer(), t).encodeToByteArray() + ) + } + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetReceiver.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetReceiver.kt new file mode 100644 index 000000000..ba8cbd6df --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetReceiver.kt @@ -0,0 +1,25 @@ +package com.lighthouse.presentation.ui.widget + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import com.lighthouse.presentation.background.BeepWorkManager + +class BeepWidgetReceiver : GlanceAppWidgetReceiver() { + + override val glanceAppWidget: GlanceAppWidget + get() = BeepWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + BeepWorkManager.getInstance(context).widgetEnqueue() + } + + /** + * 이 친구는 모든 위젯이 없어졌을때 실행될 얘입니다. + */ + override fun onDisabled(context: Context) { + super.onDisabled(context) + BeepWorkManager.getInstance(context).widgetCancel() + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetWorker.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetWorker.kt new file mode 100644 index 000000000..a4e9e95ee --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/BeepWidgetWorker.kt @@ -0,0 +1,152 @@ +package com.lighthouse.presentation.ui.widget + +import android.Manifest +import android.content.Context +import android.os.Build +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.appwidget.updateAll +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.lighthouse.domain.model.DbResult +import com.lighthouse.domain.usecase.GetBrandPlaceInfosUseCase +import com.lighthouse.domain.usecase.GetGifticonsUseCase +import com.lighthouse.domain.usecase.GetUserLocationUseCase +import com.lighthouse.presentation.mapper.toPresentation +import com.lighthouse.presentation.util.permission.core.checkPermission +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform +import kotlinx.coroutines.launch + +private var count = 0 + +@HiltWorker +class BeepWidgetWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + getGifticonsUseCase: GetGifticonsUseCase, + private val getBrandPlaceInfosUseCase: GetBrandPlaceInfosUseCase, + private val getUserLocationUseCase: GetUserLocationUseCase +) : CoroutineWorker(context, workerParams) { + + private val gifticonsDbResult = getGifticonsUseCase.getUsableGifticons() + .stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, DbResult.Loading) + + private val allBrands = gifticonsDbResult.transform { gifticons -> + if (gifticons is DbResult.Success) { + emit(gifticons.data.map { it.brand.lowercase() }.distinct()) + } + }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, emptyList()) + + private val gifticonsCount = gifticonsDbResult.transform { gifticons -> + if (gifticons is DbResult.Success) { + val gifticonGroup = gifticons.data + .groupBy { it.brand.lowercase() } + .map { it.key to it.value.count() } + .toMap() + emit(gifticonGroup) + } + }.stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Eagerly, emptyMap()) + + private var job: Job? = null + private var isSearchStart = WorkerState.WAITED + + override suspend fun doWork(): Result { + setWidgetState(WidgetState.Loading) + if (job?.isActive == true) job?.cancel() + return when (hasLocationPermission()) { + true -> { + startWidget() + delay(2000L) + if (isSearchStart == WorkerState.WAITED && count < MAX_COUNT) { + count++ + Result.retry() + } else if (count == MAX_COUNT) { + count = 0 + setWidgetState(WidgetState.Unavailable("알 수 없는 오류가 발생했습니다.")) + Result.failure() + } else { + count = 0 + Result.success() + } + } + false -> { + count = 0 + setWidgetState(WidgetState.NoExistsLocationPermission) + Result.failure() + } + } + } + + private suspend fun startWidget() { + job = CoroutineScope(Dispatchers.IO).launch { + val lastLocation = getUserLocationUseCase().first() + isSearchStart = WorkerState.STARTED + getNearBrands(lastLocation.longitude, lastLocation.latitude) + } + } + + private suspend fun getNearBrands(x: Double, y: Double) { + getBrandPlaceInfosUseCase(allBrands.value, x, y, SEARCH_SIZE) + .mapCatching { brandPlaceInfos -> brandPlaceInfos.toPresentation() } + .onSuccess { brandPlaceInfoUiModel -> + val nearGifticonBrands = brandPlaceInfoUiModel + .map { Pair(it.brand, it.categoryName) } + .distinct() + .toMap() + + val nearGifticonCount = gifticonsCount.value.filter { gifticon -> + nearGifticonBrands[gifticon.key] != null + } + + val gifticonAndBrandWithCategory = nearGifticonBrands.map { it to nearGifticonCount[it.key] } + + when (nearGifticonCount.isEmpty()) { + true -> setWidgetState(WidgetState.Empty) + false -> setWidgetState(WidgetState.Available(gifticonAndBrandWithCategory)) + } + } + .onFailure { throwable -> + setWidgetState(WidgetState.Unavailable(throwable.message.orEmpty())) + } + isSearchStart = WorkerState.ENDED + } + + private suspend fun setWidgetState(state: WidgetState) { + val glanceIds = GlanceAppWidgetManager(context).getGlanceIds(BeepWidget::class.java) + glanceIds.forEach { id -> + updateAppWidgetState( + context = context, + definition = BeepWidgetInfoStateDefinition, + glanceId = id, + updateState = { state } + ) + } + BeepWidget().updateAll(context) + } + + private fun hasLocationPermission() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.checkPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } else { + context.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION) || + context.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + } + + companion object { + private const val SEARCH_SIZE = 15 + private const val MAX_COUNT = 3 + } +} + +enum class WorkerState { + WAITED, STARTED, ENDED +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/widget/WidgetState.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/WidgetState.kt new file mode 100644 index 000000000..a4486318b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/WidgetState.kt @@ -0,0 +1,22 @@ +package com.lighthouse.presentation.ui.widget + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface WidgetState { + + @Serializable + object Loading : WidgetState + + @Serializable + object Empty : WidgetState + + @Serializable + object NoExistsLocationPermission : WidgetState + + @Serializable + data class Available(val gifticons: List, Int?>>) : WidgetState + + @Serializable + data class Unavailable(val message: String) : WidgetState +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/ui/widget/component/GlanceKtx.kt b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/component/GlanceKtx.kt new file mode 100644 index 000000000..7a20d4b29 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/ui/widget/component/GlanceKtx.kt @@ -0,0 +1,54 @@ +package com.lighthouse.presentation.ui.widget.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.ColumnScope +import androidx.glance.layout.fillMaxSize +import com.lighthouse.presentation.R + +@Composable +fun AppWidgetBox( + modifier: GlanceModifier = GlanceModifier, + contentAlignment: Alignment = Alignment.TopStart, + content: @Composable () -> Unit +) { + Box( + modifier = appWidgetBackgroundModifier().then(modifier), + contentAlignment = contentAlignment, + content = content + ) +} + +@Composable +fun AppWidgetColumn( + modifier: GlanceModifier = GlanceModifier, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + horizonAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = appWidgetBackgroundModifier().then(modifier), + verticalAlignment = verticalAlignment, + horizontalAlignment = horizonAlignment, + content = content + ) +} + +@Composable +fun appWidgetBackgroundModifier() = GlanceModifier + .fillMaxSize() + .appWidgetBackground() + .background(R.color.widget_background) + .appWidgetBackgroundCornerRadius() + +fun GlanceModifier.appWidgetBackgroundCornerRadius(): GlanceModifier { + cornerRadius(12.dp) + return this +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/BarcodeUtil.kt b/presentation/src/main/java/com/lighthouse/presentation/util/BarcodeUtil.kt new file mode 100644 index 000000000..9e191f6c4 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/BarcodeUtil.kt @@ -0,0 +1,68 @@ +package com.lighthouse.presentation.util + +import android.content.Context +import android.graphics.Bitmap +import android.widget.ImageView +import androidx.annotation.ColorInt +import com.google.zxing.BarcodeFormat +import com.google.zxing.oned.Code128Writer +import com.lighthouse.presentation.R +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class BarcodeUtil @Inject constructor(@ApplicationContext val context: Context) { + fun displayBitmap(imageView: ImageView, value: String) { + val widthPixels = context.resources.getDimensionPixelSize(R.dimen.width_barcode) + val heightPixels = context.resources.getDimensionPixelSize(R.dimen.height_barcode) + + imageView.setImageBitmap( + createBarcodeBitmap( + barcodeValue = value, + widthPixels = widthPixels, + heightPixels = heightPixels, + barcodeColor = context.getColor(R.color.black), + backgroundColor = context.getColor(android.R.color.white) + ) + ) + } + + fun createBarcodeBitmap( + barcodeValue: String, + widthPixels: Int, + heightPixels: Int, + @ColorInt barcodeColor: Int = context.getColor(R.color.black), + @ColorInt backgroundColor: Int = context.getColor(android.R.color.white) + ): Bitmap { + val bitMatrix = Code128Writer().encode( + barcodeValue, + BarcodeFormat.CODE_128, + widthPixels, + heightPixels + ) + + val pixels = IntArray(bitMatrix.width * bitMatrix.height) + for (y in 0 until bitMatrix.height) { + val offset = y * bitMatrix.width + for (x in 0 until bitMatrix.width) { + pixels[offset + x] = + if (bitMatrix.get(x, y)) barcodeColor else backgroundColor + } + } + + val bitmap = Bitmap.createBitmap( + bitMatrix.width, + bitMatrix.height, + Bitmap.Config.ARGB_8888 + ) + bitmap.setPixels( + pixels, + 0, + bitMatrix.width, + 0, + 0, + bitMatrix.width, + bitMatrix.height + ) + return bitmap + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/Geography.kt b/presentation/src/main/java/com/lighthouse/presentation/util/Geography.kt new file mode 100644 index 000000000..6dfe73656 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/Geography.kt @@ -0,0 +1,44 @@ +package com.lighthouse.presentation.util + +import android.content.Context +import android.location.Address +import android.location.Geocoder +import android.os.Build +import com.lighthouse.domain.VertexLocation +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class Geography @Inject constructor(@ApplicationContext private val context: Context) { + + // 위도 경도로 주소 구하는 Reverse-GeoCoding + fun getAddress(position: VertexLocation?): String { + position ?: return "" + + val geoCoder = Geocoder(context) + var addr = "-" + + // GRPC 오류 대응 + try { + var location: Address? = null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + geoCoder.getFromLocation(position.latitude, position.longitude, 1) { + location = it.first() + } + } else { + location = geoCoder.getFromLocation(position.latitude, position.longitude, 1)?.first() + } + addr = listOfNotNull( + location?.adminArea, + location?.subAdminArea, + location?.locality, + location?.subLocality, + location?.thoroughfare + ).joinToString(" ") + } catch (e: Exception) { + e.printStackTrace() + } + return addr + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/OnThrottleClickListener.kt b/presentation/src/main/java/com/lighthouse/presentation/util/OnThrottleClickListener.kt new file mode 100644 index 000000000..002b343a4 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/OnThrottleClickListener.kt @@ -0,0 +1,25 @@ +package com.lighthouse.presentation.util + +import android.view.View +import android.view.View.OnClickListener + +abstract class OnThrottleClickListener : OnClickListener { + + private var recentClickTime: Long = 0L + + private val isSafe: Boolean + get() = System.currentTimeMillis() - recentClickTime > THROTTLE_TIME + + override fun onClick(view: View) { + if (isSafe) { + recentClickTime = System.currentTimeMillis() + onThrottleClick(view) + } + } + + abstract fun onThrottleClick(view: View) + + companion object { + private const val THROTTLE_TIME = 500L + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/TimeCalculator.kt b/presentation/src/main/java/com/lighthouse/presentation/util/TimeCalculator.kt new file mode 100644 index 000000000..0474defe5 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/TimeCalculator.kt @@ -0,0 +1,20 @@ +package com.lighthouse.presentation.util + +import com.lighthouse.domain.util.calcDday +import java.util.Date + +object TimeCalculator { + + private const val EXPIRED_DAY = -1 + const val MAX_DAY = 365 + const val MIN_DAY = 0 + + fun formatDdayToInt(endTime: Long): Int { + val diffDate = Date(endTime).calcDday() + return when { + diffDate in MIN_DAY until MAX_DAY -> diffDate + diffDate >= MAX_DAY -> MAX_DAY + else -> EXPIRED_DAY + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/UUID.kt b/presentation/src/main/java/com/lighthouse/presentation/util/UUID.kt new file mode 100644 index 000000000..ff63240bc --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/UUID.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.util + +import java.util.UUID + +object UUID { + fun generate() = UUID.randomUUID().toString() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/flow/Combine.kt b/presentation/src/main/java/com/lighthouse/presentation/util/flow/Combine.kt new file mode 100644 index 000000000..ebeed980b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/flow/Combine.kt @@ -0,0 +1,29 @@ +package com.lighthouse.presentation.util.flow + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +/** + * 6 개의 플로우를 combine + */ +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R +): Flow = combine( + combine(flow, flow2, flow3, ::Triple), + combine(flow4, flow5, flow6, ::Triple) +) { t1, t2 -> + transform( + t1.first, + t1.second, + t1.third, + t2.first, + t2.second, + t2.third + ) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/flow/EventFlow.kt b/presentation/src/main/java/com/lighthouse/presentation/util/flow/EventFlow.kt new file mode 100644 index 000000000..7dd61a771 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/flow/EventFlow.kt @@ -0,0 +1,42 @@ +package com.lighthouse.presentation.util.flow + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import java.util.concurrent.atomic.AtomicBoolean + +interface EventFlow : Flow + +interface MutableEventFlow : EventFlow, FlowCollector + +@Suppress("FunctionName") +fun MutableEventFlow( + replay: Int = 3 +): MutableEventFlow = EventFlowImpl(replay) + +fun MutableEventFlow.asEventFlow(): EventFlow = ReadOnlyEventFlow(this) + +private class ReadOnlyEventFlow(flow: EventFlow) : EventFlow by flow + +private class EventFlowImpl( + replay: Int +) : MutableEventFlow { + + private val flow = MutableSharedFlow>(replay = replay) + + override suspend fun collect(collector: FlowCollector) = flow.collect { slot -> + if (!slot.isHandled()) { + collector.emit(slot.value) + } + } + + override suspend fun emit(value: T) { + flow.emit(EventFlowSlot(value)) + } +} + +private class EventFlowSlot(val value: T) { + private val handled: AtomicBoolean = AtomicBoolean(false) + + fun isHandled() = handled.getAndSet(true) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/parser/BaseParser.kt b/presentation/src/main/java/com/lighthouse/presentation/util/parser/BaseParser.kt new file mode 100644 index 000000000..682783d8b --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/parser/BaseParser.kt @@ -0,0 +1,79 @@ +package com.lighthouse.presentation.util.parser + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +abstract class BaseParser(input: List) { + open val keywords = listOf() + + open val exactExcludes = listOf("상품명", "교환처", "사용처", "유효기간", "사용기한", "쿠폰번호", "주문번호") + + open val containExcludes = listOf() + + protected val info by lazy { + input.filter { + val value = it.lowercase().trim() + keywords.none { keyword -> value.contains(keyword) } && + value !in exactExcludes && + containExcludes.none { exclude -> value.contains(exclude) } + }.distinct() + } + + open val match by lazy { + input.isNotEmpty() && input.any { str -> + keywords.any { keyword -> + str.lowercase().trim().contains(keyword) + } + } + } + + open val title = "" + + open val brand = "" + + private val dateRegex = "(\\d{4}.\\d{2}.\\d{2})".toRegex() + private val format = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) + val date by lazy { + info.mapNotNull { str -> + dateRegex.find(str)?.groups?.get(1)?.value?.trim() + }.getOrNull(0)?.let { + format.parse(it) + } ?: Date() + } + + private val barcodeRegex1 = "(\\d{4}\\s\\d{4}\\s\\d{4}\\s\\d{4})".toRegex() + private val barcodeRegex2 = "(\\d{4}\\s\\d{4}\\s\\d{4})".toRegex() + val barcode by lazy { + info.mapNotNull { str -> + barcodeRegex1.find(str)?.groups?.get(1)?.value?.trim()?.replace(" ", "") + ?: barcodeRegex2.find(str)?.groups?.get(1)?.value?.trim()?.replace(" ", "") + }.getOrNull(0) ?: "" + } + + private val cashCardRegex1 = "(\\d*)원".toRegex() + private val cashCardRegex2 = "(\\d*)만원".toRegex() + + val isCashCard by lazy { + balance > 0 + } + + val balance by lazy { + info.mapNotNull { str -> + cashCardRegex1.find(str)?.groups?.get(1)?.value?.let { + if (it.isNotBlank()) it.trim().toInt() else 0 + } ?: cashCardRegex2.find(str)?.groups?.get(1)?.value?.let { + if (it.isNotBlank()) it.trim().toInt() * 10000 else 0 + } + }.getOrNull(0) ?: 0 + } + + open val candidateList by lazy { + info.filter { str -> + !str.all { it.isDigit() } && + barcodeRegex1.find(str) == null && + barcodeRegex2.find(str) == null && + dateRegex.find(str) == null + } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/parser/CConParser.kt b/presentation/src/main/java/com/lighthouse/presentation/util/parser/CConParser.kt new file mode 100644 index 000000000..abf1f5d14 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/parser/CConParser.kt @@ -0,0 +1,25 @@ +package com.lighthouse.presentation.util.parser + +class CConParser(input: List) : BaseParser(input) { + override val keywords = listOf("ccon", "c콘") + + override val exactExcludes = listOf("înumber", "inumber", "상품명", "교환처", "유효기간", "쿠폰번호") + + override val containExcludes = listOf() + + private val titleRegex = "상품명\\s+(.*)".toRegex() + private val brandRegex = "교환처\\s+(.*)".toRegex() + + override val title = info.mapNotNull { str -> + titleRegex.find(str)?.groups?.get(1)?.value?.trim() + }.getOrNull(0) ?: "" + + override val brand = info.mapNotNull { str -> + brandRegex.find(str)?.groups?.get(1)?.value?.trim() + }.getOrNull(0) ?: "" + + override val candidateList = super.candidateList.map { str -> + titleRegex.find(str)?.groups?.get(1)?.value?.trim() ?: brandRegex.find(str)?.groups?.get(1)?.value?.trim() + ?: str + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/parser/DefaultParser.kt b/presentation/src/main/java/com/lighthouse/presentation/util/parser/DefaultParser.kt new file mode 100644 index 000000000..2287bc299 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/parser/DefaultParser.kt @@ -0,0 +1,17 @@ +package com.lighthouse.presentation.util.parser + +class DefaultParser(input: List) : BaseParser(input) { + override val keywords = listOf("gifticon") + + override val match by lazy { + false + } + + override val title by lazy { + candidateList.getOrElse(0) { "" } + } + + override val brand by lazy { + candidateList.getOrElse(1) { "" } + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/parser/GifticonParser.kt b/presentation/src/main/java/com/lighthouse/presentation/util/parser/GifticonParser.kt new file mode 100644 index 000000000..f3ea48fee --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/parser/GifticonParser.kt @@ -0,0 +1,27 @@ +package com.lighthouse.presentation.util.parser + +object GifticonParser { + fun parse(input: List): BaseParser { + val kakaoParser = KakaoParser(input) + if (kakaoParser.match) { + return kakaoParser + } + val syrupParser = SyrupParser(input) + if (syrupParser.match) { + return syrupParser + } + val cConParser = CConParser(input) + if (cConParser.match) { + return cConParser + } + val giftishowParser = GiftishowParser(input) + if (giftishowParser.match) { + return giftishowParser + } + return DefaultParser(input) + } + + fun parse(input: String): BaseParser { + return parse(input.split(input, "\n")) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/parser/GiftishowParser.kt b/presentation/src/main/java/com/lighthouse/presentation/util/parser/GiftishowParser.kt new file mode 100644 index 000000000..3c66d8602 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/parser/GiftishowParser.kt @@ -0,0 +1,32 @@ +package com.lighthouse.presentation.util.parser + +class GiftishowParser(input: List) : BaseParser(input) { + override val keywords = listOf("기프티쇼", "giftishow") + + override val exactExcludes = listOf("상품명", "교환처", "유효기간") + + override val containExcludes = listOf() + + private val titleRegex = "상품명\\s*:(.*)".toRegex() + private val brandRegex = "교환처\\s*:(.*)".toRegex() + private val remainRegex = ":(.*)".toRegex() + + override val title = info.mapNotNull { str -> + titleRegex.find(str)?.groups?.get(1)?.value?.trim() + }.getOrNull(0) ?: info.mapNotNull { str -> + remainRegex.find(str)?.groups?.get(1)?.value?.trim() + }.getOrNull(0) ?: "" + + override val brand = info.mapNotNull { str -> + brandRegex.find(str)?.groups?.get(1)?.value?.trim() + }.getOrNull(0) ?: info.mapNotNull { str -> + remainRegex.find(str)?.groups?.get(1)?.value?.trim() + }.let { list -> + list.getOrNull((list.size - 1).coerceAtLeast(1)) + } ?: "" + + override val candidateList = super.candidateList.map { str -> + titleRegex.find(str)?.groups?.get(1)?.value?.trim() ?: brandRegex.find(str)?.groups?.get(1)?.value?.trim() + ?: remainRegex.find(str)?.groups?.get(1)?.value?.trim() ?: str + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/parser/KakaoParser.kt b/presentation/src/main/java/com/lighthouse/presentation/util/parser/KakaoParser.kt new file mode 100644 index 000000000..1d36014c7 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/parser/KakaoParser.kt @@ -0,0 +1,9 @@ +package com.lighthouse.presentation.util.parser + +class KakaoParser(input: List) : BaseParser(input) { + override val keywords = listOf("kakaotalk") + + override val exactExcludes = listOf("교환처", "유효기간", "주문번호") + + override val containExcludes = listOf() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/parser/SyrupParser.kt b/presentation/src/main/java/com/lighthouse/presentation/util/parser/SyrupParser.kt new file mode 100644 index 000000000..9c395be41 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/parser/SyrupParser.kt @@ -0,0 +1,25 @@ +package com.lighthouse.presentation.util.parser + +class SyrupParser(input: List) : BaseParser(input) { + override val keywords = listOf("gifticon", "syrup") + + override val exactExcludes = listOf("사용기한", "사용처") + + override val containExcludes = listOf("교환수량", "기프티콘") + + override val match by lazy { + input.any { str -> + str.lowercase().trim().contains("syrup") + } || input.count { str -> str.lowercase().trim().contains("gifticon") } == 2 + } + + private val brandRegex = "사용처\\s(.*)".toRegex() + + override val brand = info.mapNotNull { + brandRegex.find(it)?.groups?.get(1)?.value?.trim() + }.getOrNull(0) ?: "" + + override val candidateList = super.candidateList.map { str -> + brandRegex.find(str)?.groups?.get(1)?.value?.trim() ?: str + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/LocationPermissionManager.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/LocationPermissionManager.kt new file mode 100644 index 000000000..c049b2a16 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/LocationPermissionManager.kt @@ -0,0 +1,18 @@ +package com.lighthouse.presentation.util.permission + +import android.Manifest +import android.app.Activity +import android.os.Build +import com.lighthouse.presentation.util.permission.core.PermissionManager + +class LocationPermissionManager(activity: Activity) : PermissionManager(activity) { + + override val additionalPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } else { + emptyArray() + } + + override val permissions = + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/StoragePermissionManager.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/StoragePermissionManager.kt new file mode 100644 index 000000000..49d3953ff --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/StoragePermissionManager.kt @@ -0,0 +1,15 @@ +package com.lighthouse.presentation.util.permission + +import android.Manifest +import android.app.Activity +import android.os.Build +import com.lighthouse.presentation.util.permission.core.PermissionManager + +class StoragePermissionManager(activity: Activity) : PermissionManager(activity) { + + override val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf(Manifest.permission.READ_MEDIA_IMAGES) + } else { + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/ActivityPermissionDelegate.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/ActivityPermissionDelegate.kt new file mode 100644 index 000000000..a3dd8d89c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/ActivityPermissionDelegate.kt @@ -0,0 +1,26 @@ +package com.lighthouse.presentation.util.permission.core + +import android.app.Activity +import androidx.core.app.ComponentActivity +import androidx.lifecycle.LifecycleOwner +import kotlin.reflect.KProperty + +class ActivityPermissionDelegate( + lifecycleOwner: LifecycleOwner, + private val activity: Activity, + private val managerClass: Class +) : PermissionDelegate(lifecycleOwner) { + + override fun getValue(thisRef: LifecycleOwner, property: KProperty<*>): PM { + manager?.let { + return it + } + + return PermissionFactory().create(activity, managerClass).also { + manager = it + } + } +} + +inline fun ComponentActivity.permissions() = + ActivityPermissionDelegate(this, this, PM::class.java) diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/BeepPermissionState.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/BeepPermissionState.kt new file mode 100644 index 000000000..564d31e82 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/BeepPermissionState.kt @@ -0,0 +1,7 @@ +package com.lighthouse.presentation.util.permission.core + +sealed class BeepPermissionState { + object NotAllowedPermission : BeepPermissionState() + object AllAllowedPermission : BeepPermissionState() + object PartiallyAllowedPermission : BeepPermissionState() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/FragmentPermissionDelegate.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/FragmentPermissionDelegate.kt new file mode 100644 index 000000000..d615df72e --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/FragmentPermissionDelegate.kt @@ -0,0 +1,25 @@ +package com.lighthouse.presentation.util.permission.core + +import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner +import kotlin.reflect.KProperty + +class FragmentPermissionDelegate( + lifecycleOwner: LifecycleOwner, + private val fragment: Fragment, + private val managerClass: Class +) : PermissionDelegate(lifecycleOwner) { + + override fun getValue(thisRef: LifecycleOwner, property: KProperty<*>): PM { + manager?.let { + return it + } + + return PermissionFactory().create(fragment.requireActivity(), managerClass).also { + manager = it + } + } +} + +inline fun Fragment.permissions() = + FragmentPermissionDelegate(this, this, PM::class.java) diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionDelegate.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionDelegate.kt new file mode 100644 index 000000000..f0d0cd76e --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionDelegate.kt @@ -0,0 +1,27 @@ +package com.lighthouse.presentation.util.permission.core + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlin.properties.ReadOnlyProperty + +abstract class PermissionDelegate( + lifecycleOwner: LifecycleOwner +) : ReadOnlyProperty { + + protected var manager: PM? = null + + init { + lifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + val manager = manager + if (manager != null) { + manager.permissionFlow.value = manager.isGrant + } + } + + override fun onDestroy(owner: LifecycleOwner) { + lifecycleOwner.lifecycle.removeObserver(this) + } + }) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionFactory.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionFactory.kt new file mode 100644 index 000000000..512ff043c --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionFactory.kt @@ -0,0 +1,15 @@ +package com.lighthouse.presentation.util.permission.core + +import android.app.Activity +import com.lighthouse.presentation.util.permission.LocationPermissionManager +import com.lighthouse.presentation.util.permission.StoragePermissionManager + +class PermissionFactory { + fun create(activity: Activity, managerClass: Class): PM { + return when (managerClass) { + StoragePermissionManager::class.java -> StoragePermissionManager(activity) + LocationPermissionManager::class.java -> LocationPermissionManager(activity) + else -> throw IllegalArgumentException("존재하지 않는 Permission Manager입니다.") + } as PM + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionManager.kt b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionManager.kt new file mode 100644 index 000000000..0e45bd12f --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/permission/core/PermissionManager.kt @@ -0,0 +1,42 @@ +package com.lighthouse.presentation.util.permission.core + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import kotlinx.coroutines.flow.MutableStateFlow + +abstract class PermissionManager( + private val activity: Activity +) { + abstract val permissions: Array + + open val additionalPermission = emptyArray() + + val basicPermission + get() = permissions.firstOrNull() ?: "" + + val permissionFlow by lazy { + MutableStateFlow(isGrant) + } + + val isGrant + get() = permissions.all { permission -> + activity.checkPermission(permission) + } + + val permissionState + get() = when { + (permissions + additionalPermission).all { activity.checkPermission(it) } -> { + BeepPermissionState.AllAllowedPermission + } + permissions.all { activity.checkPermission(it) } -> { + BeepPermissionState.PartiallyAllowedPermission + } + else -> { + BeepPermissionState.NotAllowedPermission + } + } +} + +fun Context.checkPermission(permission: String) = + checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/recycler/GridSectionSpaceItemDecoration.kt b/presentation/src/main/java/com/lighthouse/presentation/util/recycler/GridSectionSpaceItemDecoration.kt new file mode 100644 index 000000000..b14d6c572 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/recycler/GridSectionSpaceItemDecoration.kt @@ -0,0 +1,92 @@ +package com.lighthouse.presentation.util.recycler + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.GridLayoutManager.LayoutParams +import androidx.recyclerview.widget.RecyclerView + +class GridSectionSpaceItemDecoration( + private val sectionDivider: Int, + private val itemSpace: Int, + private val scrollStart: Int = 0, + private val scrollEnd: Int = 0 +) : RecyclerView.ItemDecoration() { + + constructor( + sectionDivider: Float, + itemSpace: Float, + scrollStart: Float = 0f, + scrollEnd: Float = 0f + ) : this(sectionDivider.toInt(), itemSpace.toInt(), scrollStart.toInt(), scrollEnd.toInt()) + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val manager = parent.layoutManager as? GridLayoutManager ?: return + val params = view.layoutParams as? LayoutParams ?: return + + val position = parent.getChildAdapterPosition(view) + val itemCount = parent.adapter?.itemCount ?: 0 + + val isVertical = manager.orientation == GridLayoutManager.VERTICAL + if (isVertical) { + calculateVerticalOffsets(outRect, manager, params, position, itemCount) + } else { + calculateHorizontalOffsets(outRect, manager, params, position, itemCount) + } + } + + private fun calculateVerticalOffsets( + outRect: Rect, + manager: GridLayoutManager, + params: LayoutParams, + position: Int, + itemCount: Int + ) { + outRect.left = itemSpace / 2 + outRect.top = if (isSection(manager, params)) { + if (isFirstRow(manager, position)) scrollStart else sectionDivider - itemSpace / 2 + } else { + itemSpace / 2 + } + outRect.right = itemSpace / 2 + outRect.bottom = if (isLastRow(manager, position, itemCount)) scrollEnd else itemSpace / 2 + } + + private fun calculateHorizontalOffsets( + outRect: Rect, + manager: GridLayoutManager, + params: LayoutParams, + position: Int, + itemCount: Int + ) { + outRect.left = if (isSection(manager, params)) { + if (isFirstRow(manager, position)) scrollStart else sectionDivider - itemSpace / 2 + } else { + itemSpace / 2 + } + outRect.top = itemSpace / 2 + outRect.right = if (isLastRow(manager, position, itemCount)) scrollEnd else itemSpace / 2 + outRect.bottom = itemSpace / 2 + } + + private fun isSection(manager: GridLayoutManager, params: LayoutParams): Boolean { + return manager.spanCount == params.spanSize + } + + private fun isFirstRow(manager: GridLayoutManager, position: Int): Boolean { + return getGroupIndex(manager, position) == getGroupIndex(manager, 0) + } + + private fun isLastRow(manager: GridLayoutManager, position: Int, itemCount: Int): Boolean { + return getGroupIndex(manager, position) == getGroupIndex(manager, itemCount - 1) + } + + private fun getGroupIndex(manager: GridLayoutManager, position: Int): Int { + return manager.spanSizeLookup.getSpanGroupIndex(position, manager.spanCount) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/recycler/GridSpaceItemDecoration.kt b/presentation/src/main/java/com/lighthouse/presentation/util/recycler/GridSpaceItemDecoration.kt new file mode 100644 index 000000000..b0ce81a55 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/recycler/GridSpaceItemDecoration.kt @@ -0,0 +1,76 @@ +package com.lighthouse.presentation.util.recycler + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class GridSpaceItemDecoration( + private val vSpace: Int, + private val hSpace: Int, + private val scrollStart: Int = 0, + private val scrollEnd: Int = 0 +) : RecyclerView.ItemDecoration() { + + constructor( + vSpace: Float, + hSpace: Float, + scrollStart: Float = 0f, + scrollEnd: Float = 0f + ) : this(vSpace.toInt(), hSpace.toInt(), scrollStart.toInt(), scrollEnd.toInt()) + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val manager = parent.layoutManager as? GridLayoutManager ?: return + + val position = parent.getChildAdapterPosition(view) + val itemCount = parent.adapter?.itemCount ?: 0 + + val isVertical = manager.orientation == GridLayoutManager.VERTICAL + if (isVertical) { + calculateVerticalOffsets(outRect, manager, position, itemCount) + } else { + calculateHorizontalOffsets(outRect, manager, position, itemCount) + } + } + + private fun calculateVerticalOffsets( + outRect: Rect, + manager: GridLayoutManager, + position: Int, + itemCount: Int + ) { + outRect.left = hSpace / 2 + outRect.top = if (isFirstRow(manager, position)) scrollStart else vSpace / 2 + outRect.right = hSpace / 2 + outRect.bottom = if (isLastRow(manager, position, itemCount)) scrollEnd else vSpace / 2 + } + + private fun calculateHorizontalOffsets( + outRect: Rect, + manager: GridLayoutManager, + position: Int, + itemCount: Int + ) { + outRect.left = if (isFirstRow(manager, position)) scrollStart else hSpace / 2 + outRect.top = vSpace / 2 + outRect.right = if (isLastRow(manager, position, itemCount)) scrollEnd else hSpace / 2 + outRect.bottom = vSpace / 2 + } + + private fun isFirstRow(manager: GridLayoutManager, position: Int): Boolean { + return getGroupIndex(manager, position) == getGroupIndex(manager, 0) + } + + private fun isLastRow(manager: GridLayoutManager, position: Int, itemCount: Int): Boolean { + return getGroupIndex(manager, position) == getGroupIndex(manager, itemCount - 1) + } + + private fun getGroupIndex(manager: GridLayoutManager, position: Int): Int { + return manager.spanSizeLookup.getSpanGroupIndex(position, manager.spanCount) + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/recycler/ListSpaceItemDecoration.kt b/presentation/src/main/java/com/lighthouse/presentation/util/recycler/ListSpaceItemDecoration.kt new file mode 100644 index 000000000..21da03b44 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/recycler/ListSpaceItemDecoration.kt @@ -0,0 +1,75 @@ +package com.lighthouse.presentation.util.recycler + +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class ListSpaceItemDecoration( + private val space: Int, + private val start: Int = 0, + private val top: Int = 0, + private val end: Int = 0, + private val bottom: Int = 0 +) : RecyclerView.ItemDecoration() { + + constructor( + space: Float, + start: Float = 0f, + top: Float = 0f, + end: Float = 0f, + bottom: Float = 0f + ) : this(space.toInt(), start.toInt(), top.toInt(), end.toInt(), bottom.toInt()) + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State + ) { + val manager = parent.layoutManager as? LinearLayoutManager ?: return + val itemCount = parent.adapter?.itemCount ?: 0 + val position = when (parent.getChildAdapterPosition(view)) { + 0 -> Position.Start + itemCount - 1 -> Position.End + else -> Position.Mid + } + + val isVertical = manager.orientation == LinearLayoutManager.VERTICAL + if (isVertical) { + calculateVerticalOffsets(outRect, position) + } else { + calculateHorizontalOffsets(outRect, position) + } + } + + private fun calculateVerticalOffsets(outRect: Rect, pos: Position) { + outRect.left = start + outRect.top = when (pos) { + Position.Start -> top + else -> space / 2 + } + outRect.right = end + outRect.bottom = when (pos) { + Position.End -> bottom + else -> space / 2 + } + } + + private fun calculateHorizontalOffsets(outRect: Rect, pos: Position) { + outRect.left = when (pos) { + Position.Start -> start + else -> space / 2 + } + outRect.top = top + outRect.right = when (pos) { + Position.End -> end + else -> space / 2 + } + outRect.bottom = bottom + } + + private enum class Position { + Start, End, Mid + } +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/resource/AnimInfo.kt b/presentation/src/main/java/com/lighthouse/presentation/util/resource/AnimInfo.kt new file mode 100644 index 000000000..33841b2ba --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/resource/AnimInfo.kt @@ -0,0 +1,16 @@ +package com.lighthouse.presentation.util.resource + +import android.view.animation.Animation +import androidx.annotation.AnimRes + +sealed class AnimInfo { + + object Empty : AnimInfo() + + data class DynamicAnim(val animation: Animation, val condition: Boolean) : AnimInfo() + + data class AnimResource( + @AnimRes val resId: Int, + val condition: Boolean + ) : AnimInfo() +} diff --git a/presentation/src/main/java/com/lighthouse/presentation/util/resource/UIText.kt b/presentation/src/main/java/com/lighthouse/presentation/util/resource/UIText.kt new file mode 100644 index 000000000..5b5ffa237 --- /dev/null +++ b/presentation/src/main/java/com/lighthouse/presentation/util/resource/UIText.kt @@ -0,0 +1,24 @@ +package com.lighthouse.presentation.util.resource + +import android.content.Context +import androidx.annotation.StringRes + +sealed class UIText { + + object Empty : UIText() + + data class DynamicString(val string: String) : UIText() + + class StringResource( + @StringRes val resId: Int, + vararg val args: Any + ) : UIText() + + fun asString(context: Context): String { + return when (this) { + is DynamicString -> string + is StringResource -> context.getString(resId, *args) + is Empty -> "" + } + } +} diff --git a/presentation/src/main/res/anim/anim_bounce.xml b/presentation/src/main/res/anim/anim_bounce.xml new file mode 100644 index 000000000..61cd74df1 --- /dev/null +++ b/presentation/src/main/res/anim/anim_bounce.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_fade_in.xml b/presentation/src/main/res/anim/anim_fade_in.xml new file mode 100644 index 000000000..804fa1b97 --- /dev/null +++ b/presentation/src/main/res/anim/anim_fade_in.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_fade_out.xml b/presentation/src/main/res/anim/anim_fade_out.xml new file mode 100644 index 000000000..0ef2d53e2 --- /dev/null +++ b/presentation/src/main/res/anim/anim_fade_out.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_fadein_up.xml b/presentation/src/main/res/anim/anim_fadein_up.xml new file mode 100644 index 000000000..c1572687c --- /dev/null +++ b/presentation/src/main/res/anim/anim_fadein_up.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_jump.xml b/presentation/src/main/res/anim/anim_jump.xml new file mode 100644 index 000000000..9d8fa0bdd --- /dev/null +++ b/presentation/src/main/res/anim/anim_jump.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/presentation/src/main/res/anim/anim_shake.xml b/presentation/src/main/res/anim/anim_shake.xml new file mode 100644 index 000000000..2303a7493 --- /dev/null +++ b/presentation/src/main/res/anim/anim_shake.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_slide_in_bottom.xml b/presentation/src/main/res/anim/anim_slide_in_bottom.xml new file mode 100644 index 000000000..79d6765bf --- /dev/null +++ b/presentation/src/main/res/anim/anim_slide_in_bottom.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_slide_in_left.xml b/presentation/src/main/res/anim/anim_slide_in_left.xml new file mode 100644 index 000000000..2c7e7c3f5 --- /dev/null +++ b/presentation/src/main/res/anim/anim_slide_in_left.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_slide_in_right.xml b/presentation/src/main/res/anim/anim_slide_in_right.xml new file mode 100644 index 000000000..3536ff2b3 --- /dev/null +++ b/presentation/src/main/res/anim/anim_slide_in_right.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_slide_out_left.xml b/presentation/src/main/res/anim/anim_slide_out_left.xml new file mode 100644 index 000000000..5b678eb66 --- /dev/null +++ b/presentation/src/main/res/anim/anim_slide_out_left.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_slide_out_right.xml b/presentation/src/main/res/anim/anim_slide_out_right.xml new file mode 100644 index 000000000..168291780 --- /dev/null +++ b/presentation/src/main/res/anim/anim_slide_out_right.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_slide_out_top.xml b/presentation/src/main/res/anim/anim_slide_out_top.xml new file mode 100644 index 000000000..c41b1c9d2 --- /dev/null +++ b/presentation/src/main/res/anim/anim_slide_out_top.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/presentation/src/main/res/anim/anim_stamp.xml b/presentation/src/main/res/anim/anim_stamp.xml new file mode 100644 index 000000000..50812730b --- /dev/null +++ b/presentation/src/main/res/anim/anim_stamp.xml @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/black_12.xml b/presentation/src/main/res/color/black_12.xml new file mode 100644 index 000000000..1c7b24883 --- /dev/null +++ b/presentation/src/main/res/color/black_12.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/black_36.xml b/presentation/src/main/res/color/black_36.xml new file mode 100644 index 000000000..58a838dea --- /dev/null +++ b/presentation/src/main/res/color/black_36.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/black_50.xml b/presentation/src/main/res/color/black_50.xml new file mode 100644 index 000000000..2d0438bb8 --- /dev/null +++ b/presentation/src/main/res/color/black_50.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/black_60.xml b/presentation/src/main/res/color/black_60.xml new file mode 100644 index 000000000..da3bfaae9 --- /dev/null +++ b/presentation/src/main/res/color/black_60.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/on_primary_12.xml b/presentation/src/main/res/color/on_primary_12.xml new file mode 100644 index 000000000..8311a0d06 --- /dev/null +++ b/presentation/src/main/res/color/on_primary_12.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/on_surface_12.xml b/presentation/src/main/res/color/on_surface_12.xml new file mode 100644 index 000000000..3669ba15b --- /dev/null +++ b/presentation/src/main/res/color/on_surface_12.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/on_surface_50.xml b/presentation/src/main/res/color/on_surface_50.xml new file mode 100644 index 000000000..e0628056d --- /dev/null +++ b/presentation/src/main/res/color/on_surface_50.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/on_surface_60.xml b/presentation/src/main/res/color/on_surface_60.xml new file mode 100644 index 000000000..d17f2f2d9 --- /dev/null +++ b/presentation/src/main/res/color/on_surface_60.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/on_surface_87.xml b/presentation/src/main/res/color/on_surface_87.xml new file mode 100644 index 000000000..5fc92edfd --- /dev/null +++ b/presentation/src/main/res/color/on_surface_87.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/on_surface_9.xml b/presentation/src/main/res/color/on_surface_9.xml new file mode 100644 index 000000000..8230ab5e0 --- /dev/null +++ b/presentation/src/main/res/color/on_surface_9.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/res/color/primary.xml b/presentation/src/main/res/color/primary.xml new file mode 100644 index 000000000..d2c4548d3 --- /dev/null +++ b/presentation/src/main/res/color/primary.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable-night/ic_widget_accommodation.xml b/presentation/src/main/res/drawable-night/ic_widget_accommodation.xml new file mode 100644 index 000000000..34f51e2bd --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_widget_accommodation.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable-night/ic_widget_cafe.xml b/presentation/src/main/res/drawable-night/ic_widget_cafe.xml new file mode 100644 index 000000000..621dd04d6 --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_widget_cafe.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable-night/ic_widget_convenience.xml b/presentation/src/main/res/drawable-night/ic_widget_convenience.xml new file mode 100644 index 000000000..4dc0cc306 --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_widget_convenience.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable-night/ic_widget_culture.xml b/presentation/src/main/res/drawable-night/ic_widget_culture.xml new file mode 100644 index 000000000..fc8841503 --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_widget_culture.xml @@ -0,0 +1,16 @@ + + + + diff --git a/presentation/src/main/res/drawable-night/ic_widget_market.xml b/presentation/src/main/res/drawable-night/ic_widget_market.xml new file mode 100644 index 000000000..7ef00d376 --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_widget_market.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/presentation/src/main/res/drawable-night/ic_widget_restaurant.xml b/presentation/src/main/res/drawable-night/ic_widget_restaurant.xml new file mode 100644 index 000000000..b963ab20d --- /dev/null +++ b/presentation/src/main/res/drawable-night/ic_widget_restaurant.xml @@ -0,0 +1,12 @@ + + + + diff --git a/presentation/src/main/res/drawable-nodpi/widget_gifticon_preview.jpg b/presentation/src/main/res/drawable-nodpi/widget_gifticon_preview.jpg new file mode 100644 index 000000000..78a3dff44 Binary files /dev/null and b/presentation/src/main/res/drawable-nodpi/widget_gifticon_preview.jpg differ diff --git a/presentation/src/main/res/drawable-v24/ic_launcher_foreground.xml b/presentation/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d114..000000000 --- a/presentation/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/presentation/src/main/res/drawable/anim_logo.xml b/presentation/src/main/res/drawable/anim_logo.xml new file mode 100644 index 000000000..300386364 --- /dev/null +++ b/presentation/src/main/res/drawable/anim_logo.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/drawable/bg_black_50.xml b/presentation/src/main/res/drawable/bg_black_50.xml new file mode 100644 index 000000000..241376032 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_black_50.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_bottom_sheet_handle.xml b/presentation/src/main/res/drawable/bg_bottom_sheet_handle.xml new file mode 100644 index 000000000..05627d71d --- /dev/null +++ b/presentation/src/main/res/drawable/bg_bottom_sheet_handle.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_circle_black_50.xml b/presentation/src/main/res/drawable/bg_circle_black_50.xml new file mode 100644 index 000000000..85fbc0130 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_circle_black_50.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_corner_top_20.xml b/presentation/src/main/res/drawable/bg_corner_top_20.xml new file mode 100644 index 000000000..b9de3835f --- /dev/null +++ b/presentation/src/main/res/drawable/bg_corner_top_20.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_gallery.xml b/presentation/src/main/res/drawable/bg_gallery.xml new file mode 100644 index 000000000..528b33938 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_gallery.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_gallery_selected.xml b/presentation/src/main/res/drawable/bg_gallery_selected.xml new file mode 100644 index 000000000..97932ca5b --- /dev/null +++ b/presentation/src/main/res/drawable/bg_gallery_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_gifticon_memo.xml b/presentation/src/main/res/drawable/bg_gifticon_memo.xml new file mode 100644 index 000000000..7ac95ada6 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_gifticon_memo.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_on_surface_12.xml b/presentation/src/main/res/drawable/bg_on_surface_12.xml new file mode 100644 index 000000000..979d0f084 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_on_surface_12.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_on_surface_50.xml b/presentation/src/main/res/drawable/bg_on_surface_50.xml new file mode 100644 index 000000000..16ebc85a4 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_on_surface_50.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_origin_gifticon.xml b/presentation/src/main/res/drawable/bg_origin_gifticon.xml new file mode 100644 index 000000000..6bf33aacc --- /dev/null +++ b/presentation/src/main/res/drawable/bg_origin_gifticon.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_primary_corner_4.xml b/presentation/src/main/res/drawable/bg_primary_corner_4.xml new file mode 100644 index 000000000..579338e12 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_primary_corner_4.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_splash.xml b/presentation/src/main/res/drawable/bg_splash.xml new file mode 100644 index 000000000..2f832ee5b --- /dev/null +++ b/presentation/src/main/res/drawable/bg_splash.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_stroke_bottom.xml b/presentation/src/main/res/drawable/bg_stroke_bottom.xml new file mode 100644 index 000000000..3da2f3530 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_stroke_bottom.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/bg_widget_small_6.xml b/presentation/src/main/res/drawable/bg_widget_small_6.xml new file mode 100644 index 000000000..4b3cf182c --- /dev/null +++ b/presentation/src/main/res/drawable/bg_widget_small_6.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/presentation/src/main/res/drawable/btn_main_floating_add.xml b/presentation/src/main/res/drawable/btn_main_floating_add.xml new file mode 100644 index 000000000..a9503fd36 --- /dev/null +++ b/presentation/src/main/res/drawable/btn_main_floating_add.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_add_goto_gallery.xml b/presentation/src/main/res/drawable/ic_add_goto_gallery.xml new file mode 100644 index 000000000..5306e15e2 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_add_goto_gallery.xml @@ -0,0 +1,13 @@ + + + + diff --git a/presentation/src/main/res/drawable/ic_add_warning_badge.xml b/presentation/src/main/res/drawable/ic_add_warning_badge.xml new file mode 100644 index 000000000..9f2d90a85 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_add_warning_badge.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_arrow_forward.xml b/presentation/src/main/res/drawable/ic_arrow_forward.xml new file mode 100644 index 000000000..9c932ead9 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,11 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_back.xml b/presentation/src/main/res/drawable/ic_back.xml new file mode 100644 index 000000000..1686755f3 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_check.xml b/presentation/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..cf143d4d5 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_confirm.xml b/presentation/src/main/res/drawable/ic_confirm.xml new file mode 100644 index 000000000..cf143d4d5 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_confirm.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_crop.xml b/presentation/src/main/res/drawable/ic_crop.xml new file mode 100644 index 000000000..bca05428f --- /dev/null +++ b/presentation/src/main/res/drawable/ic_crop.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_double_arrow_down.xml b/presentation/src/main/res/drawable/ic_double_arrow_down.xml new file mode 100644 index 000000000..699c5befa --- /dev/null +++ b/presentation/src/main/res/drawable/ic_double_arrow_down.xml @@ -0,0 +1,6 @@ + + + + diff --git a/presentation/src/main/res/drawable/ic_edit_gifticon_calendar.xml b/presentation/src/main/res/drawable/ic_edit_gifticon_calendar.xml new file mode 100644 index 000000000..b89360f1f --- /dev/null +++ b/presentation/src/main/res/drawable/ic_edit_gifticon_calendar.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_fingerprint.xml b/presentation/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 000000000..cf4dd0e0a --- /dev/null +++ b/presentation/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_item_invalid.xml b/presentation/src/main/res/drawable/ic_item_invalid.xml new file mode 100644 index 000000000..6e160f49b --- /dev/null +++ b/presentation/src/main/res/drawable/ic_item_invalid.xml @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_launcher_background.xml b/presentation/src/main/res/drawable/ic_launcher_background.xml index 07d5da9cb..3c0914879 100644 --- a/presentation/src/main/res/drawable/ic_launcher_background.xml +++ b/presentation/src/main/res/drawable/ic_launcher_background.xml @@ -1,170 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/presentation/src/main/res/drawable/ic_launcher_foreground.xml b/presentation/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..91ba3e94a --- /dev/null +++ b/presentation/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_location.xml b/presentation/src/main/res/drawable/ic_location.xml new file mode 100644 index 000000000..d411330b5 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_location.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_main_menu_home.xml b/presentation/src/main/res/drawable/ic_main_menu_home.xml new file mode 100644 index 000000000..3cdad40ea --- /dev/null +++ b/presentation/src/main/res/drawable/ic_main_menu_home.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_main_menu_list.xml b/presentation/src/main/res/drawable/ic_main_menu_list.xml new file mode 100644 index 000000000..7483a9873 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_main_menu_list.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_main_menu_setting.xml b/presentation/src/main/res/drawable/ic_main_menu_setting.xml new file mode 100644 index 000000000..8bda53e46 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_main_menu_setting.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_marker_accommodation.xml b/presentation/src/main/res/drawable/ic_marker_accommodation.xml new file mode 100644 index 000000000..0c5bc70df --- /dev/null +++ b/presentation/src/main/res/drawable/ic_marker_accommodation.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/presentation/src/main/res/drawable/ic_marker_base.xml b/presentation/src/main/res/drawable/ic_marker_base.xml new file mode 100644 index 000000000..71b827037 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_marker_base.xml @@ -0,0 +1,12 @@ + + + + diff --git a/presentation/src/main/res/drawable/ic_marker_cafe.xml b/presentation/src/main/res/drawable/ic_marker_cafe.xml new file mode 100644 index 000000000..7aefd17aa --- /dev/null +++ b/presentation/src/main/res/drawable/ic_marker_cafe.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_marker_convenience.xml b/presentation/src/main/res/drawable/ic_marker_convenience.xml new file mode 100644 index 000000000..002e505a1 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_marker_convenience.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_marker_culture.xml b/presentation/src/main/res/drawable/ic_marker_culture.xml new file mode 100644 index 000000000..da8a42d8c --- /dev/null +++ b/presentation/src/main/res/drawable/ic_marker_culture.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/presentation/src/main/res/drawable/ic_marker_market.xml b/presentation/src/main/res/drawable/ic_marker_market.xml new file mode 100644 index 000000000..b5511b877 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_marker_market.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_marker_restaurant.xml b/presentation/src/main/res/drawable/ic_marker_restaurant.xml new file mode 100644 index 000000000..3b5440c2f --- /dev/null +++ b/presentation/src/main/res/drawable/ic_marker_restaurant.xml @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_mode_edit.xml b/presentation/src/main/res/drawable/ic_mode_edit.xml new file mode 100644 index 000000000..1c9bd3e6b --- /dev/null +++ b/presentation/src/main/res/drawable/ic_mode_edit.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_origin_gifticon.xml b/presentation/src/main/res/drawable/ic_origin_gifticon.xml new file mode 100644 index 000000000..35960a0bd --- /dev/null +++ b/presentation/src/main/res/drawable/ic_origin_gifticon.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_outline_info.xml b/presentation/src/main/res/drawable/ic_outline_info.xml new file mode 100644 index 000000000..dca49aeef --- /dev/null +++ b/presentation/src/main/res/drawable/ic_outline_info.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_pin_backspace.xml b/presentation/src/main/res/drawable/ic_pin_backspace.xml new file mode 100644 index 000000000..3e0c1cae0 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_pin_backspace.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_pin_border.xml b/presentation/src/main/res/drawable/ic_pin_border.xml new file mode 100644 index 000000000..fdac8103e --- /dev/null +++ b/presentation/src/main/res/drawable/ic_pin_border.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_pin_filled.xml b/presentation/src/main/res/drawable/ic_pin_filled.xml new file mode 100644 index 000000000..c7611f972 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_pin_filled.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_question.xml b/presentation/src/main/res/drawable/ic_question.xml new file mode 100644 index 000000000..1d4bf0826 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_question.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_remove.xml b/presentation/src/main/res/drawable/ic_remove.xml new file mode 100644 index 000000000..d5f1bcf9b --- /dev/null +++ b/presentation/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_share.xml b/presentation/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..87cea7857 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_share.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_splash_beep.xml b/presentation/src/main/res/drawable/ic_splash_beep.xml new file mode 100644 index 000000000..6e0dba50d --- /dev/null +++ b/presentation/src/main/res/drawable/ic_splash_beep.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_widget_accommodation.xml b/presentation/src/main/res/drawable/ic_widget_accommodation.xml new file mode 100644 index 000000000..cba171967 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_widget_accommodation.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_widget_cafe.xml b/presentation/src/main/res/drawable/ic_widget_cafe.xml new file mode 100644 index 000000000..7accb9b27 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_widget_cafe.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_widget_convenience.xml b/presentation/src/main/res/drawable/ic_widget_convenience.xml new file mode 100644 index 000000000..ea5f4d029 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_widget_convenience.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_widget_culture.xml b/presentation/src/main/res/drawable/ic_widget_culture.xml new file mode 100644 index 000000000..83a38c3ab --- /dev/null +++ b/presentation/src/main/res/drawable/ic_widget_culture.xml @@ -0,0 +1,16 @@ + + + + diff --git a/presentation/src/main/res/drawable/ic_widget_market.xml b/presentation/src/main/res/drawable/ic_widget_market.xml new file mode 100644 index 000000000..2b2b89907 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_widget_market.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/presentation/src/main/res/drawable/ic_widget_refresh_24.xml b/presentation/src/main/res/drawable/ic_widget_refresh_24.xml new file mode 100644 index 000000000..788bfdfc8 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_widget_refresh_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_widget_restaurant.xml b/presentation/src/main/res/drawable/ic_widget_restaurant.xml new file mode 100644 index 000000000..866c79d6e --- /dev/null +++ b/presentation/src/main/res/drawable/ic_widget_restaurant.xml @@ -0,0 +1,12 @@ + + + + diff --git a/presentation/src/main/res/drawable/img_item_delete.xml b/presentation/src/main/res/drawable/img_item_delete.xml new file mode 100644 index 000000000..9533c3c4e --- /dev/null +++ b/presentation/src/main/res/drawable/img_item_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/drawable/img_item_invalid.xml b/presentation/src/main/res/drawable/img_item_invalid.xml new file mode 100644 index 000000000..910114f75 --- /dev/null +++ b/presentation/src/main/res/drawable/img_item_invalid.xml @@ -0,0 +1,10 @@ + + + + diff --git a/presentation/src/main/res/drawable/img_stamp.xml b/presentation/src/main/res/drawable/img_stamp.xml new file mode 100644 index 000000000..4f1686246 --- /dev/null +++ b/presentation/src/main/res/drawable/img_stamp.xml @@ -0,0 +1,16 @@ + + + + diff --git a/presentation/src/main/res/drawable/ripple_add_gifticon_candidate.xml b/presentation/src/main/res/drawable/ripple_add_gifticon_candidate.xml new file mode 100644 index 000000000..075478b6f --- /dev/null +++ b/presentation/src/main/res/drawable/ripple_add_gifticon_candidate.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ripple_circle_on_primary_12.xml b/presentation/src/main/res/drawable/ripple_circle_on_primary_12.xml new file mode 100644 index 000000000..fe97f8b8f --- /dev/null +++ b/presentation/src/main/res/drawable/ripple_circle_on_primary_12.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ripple_circle_on_surface_12.xml b/presentation/src/main/res/drawable/ripple_circle_on_surface_12.xml new file mode 100644 index 000000000..3dfdcd433 --- /dev/null +++ b/presentation/src/main/res/drawable/ripple_circle_on_surface_12.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ripple_primary_corner_4.xml b/presentation/src/main/res/drawable/ripple_primary_corner_4.xml new file mode 100644 index 000000000..a104030b2 --- /dev/null +++ b/presentation/src/main/res/drawable/ripple_primary_corner_4.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ripple_rect_corner_8_on_primary_12.xml b/presentation/src/main/res/drawable/ripple_rect_corner_8_on_primary_12.xml new file mode 100644 index 000000000..c2bd9228e --- /dev/null +++ b/presentation/src/main/res/drawable/ripple_rect_corner_8_on_primary_12.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ripple_rect_corner_8_on_surface_12.xml b/presentation/src/main/res/drawable/ripple_rect_corner_8_on_surface_12.xml new file mode 100644 index 000000000..385d778b8 --- /dev/null +++ b/presentation/src/main/res/drawable/ripple_rect_corner_8_on_surface_12.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/tc_on_primary_disabled.xml b/presentation/src/main/res/drawable/tc_on_primary_disabled.xml new file mode 100644 index 000000000..16f1b8d6e --- /dev/null +++ b/presentation/src/main/res/drawable/tc_on_primary_disabled.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/tc_on_primary_disabled_night.xml b/presentation/src/main/res/drawable/tc_on_primary_disabled_night.xml new file mode 100644 index 000000000..aa5d8bcee --- /dev/null +++ b/presentation/src/main/res/drawable/tc_on_primary_disabled_night.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/activity_add_gifticon.xml b/presentation/src/main/res/layout/activity_add_gifticon.xml new file mode 100644 index 000000000..c896d7588 --- /dev/null +++ b/presentation/src/main/res/layout/activity_add_gifticon.xml @@ -0,0 +1,749 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/activity_crop_gifticon.xml b/presentation/src/main/res/layout/activity_crop_gifticon.xml new file mode 100644 index 000000000..257ce616f --- /dev/null +++ b/presentation/src/main/res/layout/activity_crop_gifticon.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/activity_gallery.xml b/presentation/src/main/res/layout/activity_gallery.xml new file mode 100644 index 000000000..f3e39bbe1 --- /dev/null +++ b/presentation/src/main/res/layout/activity_gallery.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/activity_gifticon_detail.xml b/presentation/src/main/res/layout/activity_gifticon_detail.xml new file mode 100644 index 000000000..acca9edba --- /dev/null +++ b/presentation/src/main/res/layout/activity_gifticon_detail.xml @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +