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