From 4fada09dde03a6fdbbd40591fc83785c663bfdbf Mon Sep 17 00:00:00 2001 From: Sehwan Yun <39579912+l5x5l@users.noreply.github.com> Date: Sun, 18 Aug 2024 19:57:18 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]=20#34=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [BASE] #34 feature:alarm 모듈 생성 * [FEATURE] #34 Alert관련 API, Datasource, Repository, UseCase 구현 * [FEATURE] #34 알림함 화면 구현 및 rootNavHost에 추가 * [FIX] #34 알림함 화면에서 페이지네이션이 정상적으로 수행되지 않던 문제 수정 및 알람 제거시 애니메이션 효과 추가 * [CHORE] #34 알림함 화면 Preview 문제 해결 --- app/build.gradle.kts | 1 + .../pokit/navigation/RootDestination.kt | 4 + .../pokitmons/pokit/navigation/RootNavHost.kt | 14 +- .../java/pokitmons/pokit/data/api/AlertApi.kt | 22 +++ .../remote/alert/AlertDataSource.kt | 8 + .../remote/alert/RemoteAlertDataSource.kt | 17 +++ .../pokit/data/di/alert/AlertModule.kt | 23 +++ .../pokit/data/di/network/NetworkModule.kt | 6 + .../pokit/data/mapper/alert/AlertMapper.kt | 18 +++ .../data/model/alert/GetAlertsResponse.kt | 32 ++++ .../repository/alert/AlertRepositoryImpl.kt | 32 ++++ .../datasource/RemoteAlertDataSourceTest.kt | 48 ++++++ .../repository/AlertRepositoryImplTest.kt | 52 +++++++ .../pokit/domain/model/alert/Alarm.kt | 9 ++ .../repository/alert/AlertRepository.kt | 9 ++ .../usecase/alert/DeleteAlertUseCase.kt | 13 ++ .../domain/usecase/alert/GetAlertsUseCase.kt | 14 ++ feature/alarm/.gitignore | 1 + feature/alarm/build.gradle.kts | 74 +++++++++ feature/alarm/consumer-rules.pro | 0 feature/alarm/proguard-rules.pro | 21 +++ .../pokit/alarm/ExampleInstrumentedTest.kt | 22 +++ feature/alarm/src/main/AndroidManifest.xml | 4 + .../java/pokitmons/pokit/alarm/AlarmScreen.kt | 99 ++++++++++++ .../pokitmons/pokit/alarm/AlarmViewModel.kt | 60 ++++++++ .../java/pokitmons/pokit/alarm/Preview.kt | 19 +++ .../alarm/components/alarmitem/AlarmItem.kt | 142 ++++++++++++++++++ .../alarm/components/alarmitem/Preview.kt | 20 +++ .../pokit/alarm/components/toolbar/Preview.kt | 18 +++ .../pokit/alarm/components/toolbar/Toolbar.kt | 48 ++++++ .../java/pokitmons/pokit/alarm/model/Alarm.kt | 24 +++ .../java/pokitmons/pokit/alarm/model/Date.kt | 59 ++++++++ .../pokit/alarm/paging/AlarmPaging.kt | 125 +++++++++++++++ .../pokit/alarm/paging/SimplePaging.kt | 13 ++ .../pokit/alarm/paging/SimplePagingState.kt | 5 + .../pokit/alarm/util/DateStringUtil.kt | 36 +++++ feature/alarm/src/main/res/values/string.xml | 12 ++ .../pokitmons/pokit/alarm/ExampleUnitTest.kt | 16 ++ settings.gradle.kts | 1 + 39 files changed, 1140 insertions(+), 1 deletion(-) create mode 100644 data/src/main/java/pokitmons/pokit/data/api/AlertApi.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/AlertDataSource.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/RemoteAlertDataSource.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/di/alert/AlertModule.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/mapper/alert/AlertMapper.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/model/alert/GetAlertsResponse.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/repository/alert/AlertRepositoryImpl.kt create mode 100644 data/src/test/java/pokitmons/pokit/data/datasource/RemoteAlertDataSourceTest.kt create mode 100644 data/src/test/java/pokitmons/pokit/data/repository/AlertRepositoryImplTest.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/model/alert/Alarm.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/repository/alert/AlertRepository.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/alert/DeleteAlertUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/alert/GetAlertsUseCase.kt create mode 100644 feature/alarm/.gitignore create mode 100644 feature/alarm/build.gradle.kts create mode 100644 feature/alarm/consumer-rules.pro create mode 100644 feature/alarm/proguard-rules.pro create mode 100644 feature/alarm/src/androidTest/java/pokitmons/pokit/alarm/ExampleInstrumentedTest.kt create mode 100644 feature/alarm/src/main/AndroidManifest.xml create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmScreen.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmViewModel.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/Preview.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/AlarmItem.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/Preview.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Preview.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Toolbar.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Alarm.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Date.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/AlarmPaging.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePaging.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePagingState.kt create mode 100644 feature/alarm/src/main/java/pokitmons/pokit/alarm/util/DateStringUtil.kt create mode 100644 feature/alarm/src/main/res/values/string.xml create mode 100644 feature/alarm/src/test/java/pokitmons/pokit/alarm/ExampleUnitTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50c1535c..a150919e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -74,6 +74,7 @@ dependencies { implementation(project(":domain")) implementation(project(":feature:addlink")) implementation(project(":feature:addpokit")) + implementation(project(":feature:alarm")) implementation(project(":feature:login")) implementation(project(":feature:pokitdetail")) implementation(project(":feature:search")) diff --git a/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt b/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt index 59d6396b..5b1f1436 100644 --- a/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt +++ b/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt @@ -53,3 +53,7 @@ object Setting { object EditNickname { val route: String = "editNickname" } + +object Alarm { + val route: String = "alarm" +} diff --git a/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt b/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt index d21ac4e4..13ce39b9 100644 --- a/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt +++ b/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt @@ -16,10 +16,11 @@ import com.strayalpaca.addpokit.AddPokitViewModel import com.strayalpaca.pokitdetail.PokitDetailScreenContainer import com.strayalpaca.pokitdetail.PokitDetailViewModel import pokitmons.pokit.LoginViewModel +import pokitmons.pokit.alarm.AlarmScreenContainer +import pokitmons.pokit.alarm.AlarmViewModel import pokitmons.pokit.home.HomeScreen import pokitmons.pokit.home.pokit.PokitViewModel import pokitmons.pokit.login.LoginScreen -import pokitmons.pokit.navigation.PokitDetail.pokitIdArg import pokitmons.pokit.search.SearchScreenContainer import pokitmons.pokit.search.SearchViewModel import pokitmons.pokit.settings.SettingViewModel @@ -136,5 +137,16 @@ fun RootNavHost( onNavigateAddPokit = { navHostController.navigate(AddPokit.route) } ) } + + composable(route = Alarm.route) { + val viewModel: AlarmViewModel = hiltViewModel() + AlarmScreenContainer( + viewModel = viewModel, + onBackPressed = navHostController::popBackStack, + onNavigateToLinkModify = { linkId -> + navHostController.navigate("${AddLink.route}?${AddLink.linkIdArg}=$linkId") + } + ) + } } } diff --git a/data/src/main/java/pokitmons/pokit/data/api/AlertApi.kt b/data/src/main/java/pokitmons/pokit/data/api/AlertApi.kt new file mode 100644 index 00000000..11c92782 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/api/AlertApi.kt @@ -0,0 +1,22 @@ +package pokitmons.pokit.data.api + +import pokitmons.pokit.data.model.alert.GetAlertsResponse +import pokitmons.pokit.domain.model.link.LinksSort +import retrofit2.http.GET +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface AlertApi { + @GET("alert") + suspend fun getAlerts( + @Query("page") page: Int, + @Query("size") size: Int, + @Query("sort") sort: List = listOf(LinksSort.RECENT.value), + ): GetAlertsResponse + + @PUT("alert/{alertId}") + suspend fun deleteAlert( + @Path("alertId") alertId: Int, + ) +} diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/AlertDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/AlertDataSource.kt new file mode 100644 index 00000000..8cbaeeec --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/AlertDataSource.kt @@ -0,0 +1,8 @@ +package pokitmons.pokit.data.datasource.remote.alert + +import pokitmons.pokit.data.model.alert.GetAlertsResponse + +interface AlertDataSource { + suspend fun getAlerts(page: Int, size: Int): GetAlertsResponse + suspend fun deleteAlert(alertId: Int) +} diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/RemoteAlertDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/RemoteAlertDataSource.kt new file mode 100644 index 00000000..d9f3cf8f --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/alert/RemoteAlertDataSource.kt @@ -0,0 +1,17 @@ +package pokitmons.pokit.data.datasource.remote.alert + +import pokitmons.pokit.data.api.AlertApi +import pokitmons.pokit.data.model.alert.GetAlertsResponse +import javax.inject.Inject + +class RemoteAlertDataSource @Inject constructor( + private val api: AlertApi, +) : AlertDataSource { + override suspend fun getAlerts(page: Int, size: Int): GetAlertsResponse { + return api.getAlerts(page = page, size = size) + } + + override suspend fun deleteAlert(alertId: Int) { + return api.deleteAlert(alertId) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/di/alert/AlertModule.kt b/data/src/main/java/pokitmons/pokit/data/di/alert/AlertModule.kt new file mode 100644 index 00000000..748138e7 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/di/alert/AlertModule.kt @@ -0,0 +1,23 @@ +package pokitmons.pokit.data.di.alert + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import pokitmons.pokit.data.datasource.remote.alert.AlertDataSource +import pokitmons.pokit.data.datasource.remote.alert.RemoteAlertDataSource +import pokitmons.pokit.data.repository.alert.AlertRepositoryImpl +import pokitmons.pokit.domain.repository.alert.AlertRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class AlertModule { + @Binds + @Singleton + abstract fun bindAlertRepository(alertRepositoryImpl: AlertRepositoryImpl): AlertRepository + + @Binds + @Singleton + abstract fun bindAlertDataSource(alertDataSourceImpl: RemoteAlertDataSource): AlertDataSource +} diff --git a/data/src/main/java/pokitmons/pokit/data/di/network/NetworkModule.kt b/data/src/main/java/pokitmons/pokit/data/di/network/NetworkModule.kt index 8485c009..05389780 100644 --- a/data/src/main/java/pokitmons/pokit/data/di/network/NetworkModule.kt +++ b/data/src/main/java/pokitmons/pokit/data/di/network/NetworkModule.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import pokitmons.pokit.data.api.AlertApi import pokitmons.pokit.data.api.AuthApi import pokitmons.pokit.data.api.LinkApi import pokitmons.pokit.data.api.PokitApi @@ -91,4 +92,9 @@ object NetworkModule { fun provideRemindService(retrofit: Retrofit): RemindApi { return retrofit.create(RemindApi::class.java) } + + @Provides + fun provideAlertService(retrofit: Retrofit): AlertApi { + return retrofit.create(AlertApi::class.java) + } } diff --git a/data/src/main/java/pokitmons/pokit/data/mapper/alert/AlertMapper.kt b/data/src/main/java/pokitmons/pokit/data/mapper/alert/AlertMapper.kt new file mode 100644 index 00000000..196af500 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/mapper/alert/AlertMapper.kt @@ -0,0 +1,18 @@ +package pokitmons.pokit.data.mapper.alert + +import pokitmons.pokit.data.model.alert.GetAlertsResponse +import pokitmons.pokit.domain.model.alert.Alarm + +object AlertMapper { + fun mapperToAlarmList(response: GetAlertsResponse): List { + return response.data.map { data -> + Alarm( + id = data.id, + contentId = data.contentId, + thumbnail = data.thumbNail, + title = data.title, + createdAt = data.createdAt + ) + } + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/model/alert/GetAlertsResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/alert/GetAlertsResponse.kt new file mode 100644 index 00000000..e55692a1 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/alert/GetAlertsResponse.kt @@ -0,0 +1,32 @@ +package pokitmons.pokit.data.model.alert + +import kotlinx.serialization.Serializable + +@Serializable +data class GetAlertsResponse( + val data: List = emptyList(), + val page: Int = 0, + val size: Int = 10, + val sort: List = emptyList(), + val hasNext: Boolean = true, +) { + @Serializable + data class Data( + val id: Int, + val userId: Int, + val contentId: Int, + val thumbNail: String, + val title: String, + val body: String, + val createdAt: String, + ) + + @Serializable + data class Sort( + val direction: String, + val nullHandling: String, + val ascending: Boolean, + val property: String, + val ignoreCase: Boolean, + ) +} diff --git a/data/src/main/java/pokitmons/pokit/data/repository/alert/AlertRepositoryImpl.kt b/data/src/main/java/pokitmons/pokit/data/repository/alert/AlertRepositoryImpl.kt new file mode 100644 index 00000000..258b9ed0 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/repository/alert/AlertRepositoryImpl.kt @@ -0,0 +1,32 @@ +package pokitmons.pokit.data.repository.alert + +import pokitmons.pokit.data.datasource.remote.alert.AlertDataSource +import pokitmons.pokit.data.mapper.alert.AlertMapper +import pokitmons.pokit.data.model.common.parseErrorResult +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.alert.Alarm +import pokitmons.pokit.domain.repository.alert.AlertRepository +import javax.inject.Inject + +class AlertRepositoryImpl @Inject constructor( + private val dataSource: AlertDataSource, +) : AlertRepository { + override suspend fun getAlerts(page: Int, size: Int): PokitResult> { + return runCatching { + val response = dataSource.getAlerts(page = page, size = size) + val mappedResponse = AlertMapper.mapperToAlarmList(response) + PokitResult.Success(result = mappedResponse) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun deleteAlert(alertId: Int): PokitResult { + return runCatching { + dataSource.deleteAlert(alertId) + PokitResult.Success(result = Unit) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } +} diff --git a/data/src/test/java/pokitmons/pokit/data/datasource/RemoteAlertDataSourceTest.kt b/data/src/test/java/pokitmons/pokit/data/datasource/RemoteAlertDataSourceTest.kt new file mode 100644 index 00000000..a830d7f6 --- /dev/null +++ b/data/src/test/java/pokitmons/pokit/data/datasource/RemoteAlertDataSourceTest.kt @@ -0,0 +1,48 @@ +package pokitmons.pokit.data.datasource + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.mockk +import pokitmons.pokit.data.api.AlertApi +import pokitmons.pokit.data.datasource.remote.alert.RemoteAlertDataSource +import pokitmons.pokit.data.model.alert.GetAlertsResponse + +val alertApi: AlertApi = mockk() + +class RemoteAlertDataSourceTest : DescribeSpec({ + val remoteAlertDataSource = RemoteAlertDataSource(alertApi) + describe("알림 목록 조회시") { + context("알림 목록 조회가 정상적으로 수행되면") { + coEvery { alertApi.getAlerts(page = 0, size = 0) } returns GetAlertsResponse() + it("알림 목록이 반환된다.") { + val response = remoteAlertDataSource.getAlerts(page = 0, size = 0) + response.shouldBeInstanceOf() + } + } + + context("에러가 발생했다면") { + coEvery { alertApi.getAlerts(page = 0, size = 0) } throws IllegalArgumentException("error") + it("동일한 에러가 발생한다.") { + val exception = shouldThrow { + remoteAlertDataSource.getAlerts(page = 0, size = 0) + } + exception.message shouldBe "error" + } + } + } + + describe("알림 제거시") { + context("에러가 발생했다면") { + coEvery { alertApi.deleteAlert(alertId = 0) } throws IllegalArgumentException("error") + it("동일한 에러가 발생한다.") { + val exception = shouldThrow { + remoteAlertDataSource.deleteAlert(alertId = 0) + } + exception.message shouldBe "error" + } + } + } +}) diff --git a/data/src/test/java/pokitmons/pokit/data/repository/AlertRepositoryImplTest.kt b/data/src/test/java/pokitmons/pokit/data/repository/AlertRepositoryImplTest.kt new file mode 100644 index 00000000..10fadefc --- /dev/null +++ b/data/src/test/java/pokitmons/pokit/data/repository/AlertRepositoryImplTest.kt @@ -0,0 +1,52 @@ +package pokitmons.pokit.data.repository + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.coEvery +import io.mockk.mockk +import pokitmons.pokit.data.datasource.remote.alert.AlertDataSource +import pokitmons.pokit.data.model.alert.GetAlertsResponse +import pokitmons.pokit.data.repository.alert.AlertRepositoryImpl +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.alert.Alarm + +val alertDataSource: AlertDataSource = mockk() + +class AlertRepositoryImplTest : DescribeSpec({ + val alertRepository = AlertRepositoryImpl(alertDataSource) + describe("알림 목록 조회시") { + context("알림 목록 조회가 정상적으로 수행되면") { + coEvery { alertDataSource.getAlerts(page = 0, size = 0) } returns GetAlertsResponse() + it("PokitResult로 래핑된 알림 목록이 반환된다.") { + val response = alertRepository.getAlerts(page = 0, size = 0) + response.shouldBeInstanceOf>>() + } + } + + context("에러가 발생했다면") { + coEvery { alertDataSource.getAlerts(page = 0, size = 0) } throws IllegalArgumentException() + it("PokitResult로 래핑된 에러 내용이 반환된다.") { + val response = alertRepository.getAlerts(page = 0, size = 0) + response.shouldBeInstanceOf() + } + } + } + + describe("알림 제거시") { + context("제거가 정상적으로 수행되면") { + coEvery { alertDataSource.deleteAlert(alertId = 0) } returns Unit + it("데이터가 없는 PokitResult.Success가 반환된다.") { + val response = alertRepository.deleteAlert(alertId = 0) + response.shouldBeInstanceOf>() + } + } + + context("에러가 발생했다면") { + coEvery { alertDataSource.deleteAlert(alertId = 0) } throws IllegalArgumentException() + it("PokitResult로 래핑된 에러 내용이 반환된다.") { + val response = alertRepository.deleteAlert(alertId = 0) + response.shouldBeInstanceOf() + } + } + } +}) diff --git a/domain/src/main/java/pokitmons/pokit/domain/model/alert/Alarm.kt b/domain/src/main/java/pokitmons/pokit/domain/model/alert/Alarm.kt new file mode 100644 index 00000000..e9d6164f --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/model/alert/Alarm.kt @@ -0,0 +1,9 @@ +package pokitmons.pokit.domain.model.alert + +data class Alarm( + val id: Int, + val contentId: Int, + val thumbnail: String? = null, + val title: String, + val createdAt: String, +) diff --git a/domain/src/main/java/pokitmons/pokit/domain/repository/alert/AlertRepository.kt b/domain/src/main/java/pokitmons/pokit/domain/repository/alert/AlertRepository.kt new file mode 100644 index 00000000..5b836d9d --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/repository/alert/AlertRepository.kt @@ -0,0 +1,9 @@ +package pokitmons.pokit.domain.repository.alert + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.alert.Alarm + +interface AlertRepository { + suspend fun getAlerts(page: Int, size: Int): PokitResult> + suspend fun deleteAlert(alertId: Int): PokitResult +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/alert/DeleteAlertUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/alert/DeleteAlertUseCase.kt new file mode 100644 index 00000000..848bcad8 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/alert/DeleteAlertUseCase.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.domain.usecase.alert + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.repository.alert.AlertRepository +import javax.inject.Inject + +class DeleteAlertUseCase @Inject constructor( + private val repository: AlertRepository, +) { + suspend fun deleteAlert(alertId: Int): PokitResult { + return repository.deleteAlert(alertId) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/alert/GetAlertsUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/alert/GetAlertsUseCase.kt new file mode 100644 index 00000000..be03aec2 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/alert/GetAlertsUseCase.kt @@ -0,0 +1,14 @@ +package pokitmons.pokit.domain.usecase.alert + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.alert.Alarm +import pokitmons.pokit.domain.repository.alert.AlertRepository +import javax.inject.Inject + +class GetAlertsUseCase @Inject constructor( + private val repository: AlertRepository, +) { + suspend fun getAlerts(page: Int, size: Int): PokitResult> { + return repository.getAlerts(page = page, size = size) + } +} diff --git a/feature/alarm/.gitignore b/feature/alarm/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/alarm/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/alarm/build.gradle.kts b/feature/alarm/build.gradle.kts new file mode 100644 index 00000000..71d3f2d6 --- /dev/null +++ b/feature/alarm/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + alias(libs.plugins.com.android.library) + alias(libs.plugins.org.jetbrains.kotlin.android) + alias(libs.plugins.hilt) + id("kotlin-kapt") +} + +android { + namespace = "pokitmons.pokit.alarm" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(libs.orbit.compose) + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + + // hilt + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + // coil + implementation(libs.coil.compose) + + implementation(project(":core:ui")) + implementation(project(":domain")) +} diff --git a/feature/alarm/consumer-rules.pro b/feature/alarm/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/alarm/proguard-rules.pro b/feature/alarm/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/alarm/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/alarm/src/androidTest/java/pokitmons/pokit/alarm/ExampleInstrumentedTest.kt b/feature/alarm/src/androidTest/java/pokitmons/pokit/alarm/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..5e6a17a9 --- /dev/null +++ b/feature/alarm/src/androidTest/java/pokitmons/pokit/alarm/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package pokitmons.pokit.alarm + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("pokitmons.pokit.alarm.test", appContext.packageName) + } +} diff --git a/feature/alarm/src/main/AndroidManifest.xml b/feature/alarm/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/alarm/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmScreen.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmScreen.kt new file mode 100644 index 00000000..14ff731d --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmScreen.kt @@ -0,0 +1,99 @@ +package pokitmons.pokit.alarm + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import pokitmons.pokit.alarm.components.alarmitem.AlarmItem +import pokitmons.pokit.alarm.components.toolbar.Toolbar +import pokitmons.pokit.alarm.model.Alarm +import pokitmons.pokit.alarm.paging.SimplePagingState + +@Composable +fun AlarmScreenContainer( + viewModel: AlarmViewModel, + onNavigateToLinkModify: (String) -> Unit = {}, + onBackPressed: () -> Unit, +) { + val alarms by viewModel.alarms.collectAsState() + val alarmsState by viewModel.alarmsState.collectAsState() + + AlarmScreen( + onClickBack = onBackPressed, + onClickAlarm = remember { + { alarmId -> + viewModel.readAlarm(alarmId) + onNavigateToLinkModify(alarmId) + } + }, + onClickAlarmRemove = viewModel::removeAlarm, + alarms = alarms, + alarmsState = alarmsState, + loadNextAlarms = viewModel::loadNextAlarms + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AlarmScreen( + onClickBack: () -> Unit = {}, + onClickAlarm: (String) -> Unit = {}, + onClickAlarmRemove: (String) -> Unit = {}, + alarms: List = emptyList(), + alarmsState: SimplePagingState = SimplePagingState.IDLE, + loadNextAlarms: () -> Unit = {}, +) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Toolbar( + onClickBack = onClickBack, + title = stringResource(id = R.string.alarm_box) + ) + + val alarmLazyColumnListState = rememberLazyListState() + val startAlarmPaging = remember { + derivedStateOf { + alarmLazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { last -> + last.index >= alarmLazyColumnListState.layoutInfo.totalItemsCount - 3 + } ?: false + } + } + + LaunchedEffect(startAlarmPaging.value) { + if (startAlarmPaging.value && alarmsState == SimplePagingState.IDLE) { + loadNextAlarms() + } + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = alarmLazyColumnListState + ) { + items( + items = alarms, + key = { alarm -> alarm.id } + ) { alarm -> + AlarmItem( + modifier = Modifier.animateItemPlacement(), + alarm = alarm, + onClickAlarm = onClickAlarm, + onClickRemove = onClickAlarmRemove + ) + } + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmViewModel.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmViewModel.kt new file mode 100644 index 00000000..da09f4d2 --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/AlarmViewModel.kt @@ -0,0 +1,60 @@ +package pokitmons.pokit.alarm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import pokitmons.pokit.alarm.model.Alarm +import pokitmons.pokit.alarm.paging.AlarmPaging +import pokitmons.pokit.alarm.paging.SimplePagingState +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.alert.DeleteAlertUseCase +import pokitmons.pokit.domain.usecase.alert.GetAlertsUseCase +import javax.inject.Inject + +@HiltViewModel +class AlarmViewModel @Inject constructor( + getAlertsUseCase: GetAlertsUseCase, + private val deleteAlertUseCase: DeleteAlertUseCase, +) : ViewModel() { + + private val alarmPaging = AlarmPaging(getAlertsUseCase = getAlertsUseCase) + + val alarms: StateFlow> = alarmPaging.pagingData + val alarmsState: StateFlow = alarmPaging.pagingState + + init { + viewModelScope.launch { + alarmPaging.refresh() + } + } + + fun removeAlarm(alarmId: String) { + val id = alarmId.toIntOrNull() ?: return + viewModelScope.launch { + val response = deleteAlertUseCase.deleteAlert(id) + if (response is PokitResult.Success) { + viewModelScope.launch { + alarms.value.find { it.id == alarmId }?.let { targetItem -> + alarmPaging.deleteItem(targetItem) + } + } + } + } + } + + fun loadNextAlarms() { + viewModelScope.launch { + alarmPaging.load() + } + } + + fun readAlarm(alarmId: String) { + val targetAlarm = alarms.value.find { it.id == alarmId } ?: return + + viewModelScope.launch { + alarmPaging.modifyItem(item = targetAlarm.copy(read = true)) + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/Preview.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/Preview.kt new file mode 100644 index 00000000..be65ba09 --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/Preview.kt @@ -0,0 +1,19 @@ +package pokitmons.pokit.alarm + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import pokitmons.pokit.alarm.model.Alarm +import pokitmons.pokit.core.ui.theme.PokitTheme + +@Preview(showBackground = true) +@Composable +internal fun Preview() { + PokitTheme { + Column { + AlarmScreen( + alarms = listOf(Alarm(id = "1", title = "title1", thumbnail = ""), Alarm(id = "2", title = "title2", thumbnail = "")) + ) + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/AlarmItem.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/AlarmItem.kt new file mode 100644 index 00000000..590133f1 --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/AlarmItem.kt @@ -0,0 +1,142 @@ +package pokitmons.pokit.alarm.components.alarmitem + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import pokitmons.pokit.alarm.R +import pokitmons.pokit.alarm.model.Alarm +import pokitmons.pokit.alarm.util.diffString +import pokitmons.pokit.core.ui.components.block.pushcard.PushCard +import pokitmons.pokit.core.ui.theme.PokitTheme +import kotlin.math.roundToInt +import pokitmons.pokit.core.ui.R.drawable as coreDrawable + +enum class DragValue { Expanded, Center } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AlarmItem( + modifier: Modifier = Modifier, + alarm: Alarm, + onClickAlarm: (String) -> Unit, + onClickRemove: (String) -> Unit, +) { + val density = LocalDensity.current + val dragMaxOffset = with(density) { + (-60).dp.toPx() + } + val draggableState = remember { + AnchoredDraggableState( + anchors = DraggableAnchors { + DragValue.Expanded at dragMaxOffset + DragValue.Center at 0f + }, + initialValue = DragValue.Center, + animationSpec = tween(), + positionalThreshold = { totalDistance -> totalDistance * 0.5f }, + velocityThreshold = { with(density) { 60.dp.toPx() } } + ) + } + val timeDiffString = diffString(createdAtCalendar = alarm.createdAt.getCalendar()) + + Column( + modifier = modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .width(60.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { onClickRemove(alarm.id) } + ) + .background(PokitTheme.colors.error) + .align(Alignment.CenterEnd), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = coreDrawable.icon_24_trash), + contentDescription = "", + colorFilter = ColorFilter.tint(PokitTheme.colors.inverseWh), + modifier = Modifier.size(24.dp) + ) + Text( + text = stringResource(id = R.string.remove), + style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.inverseWh) + ) + } + + Row( + modifier = Modifier + .offset { + IntOffset( + x = draggableState + .requireOffset() + .roundToInt(), + y = 0 + ) + } + .anchoredDraggable( + state = draggableState, + orientation = Orientation.Horizontal + ) + .fillMaxWidth() + .background(PokitTheme.colors.backgroundBase), + verticalAlignment = Alignment.CenterVertically + ) { + PushCard( + title = alarm.title, + sub = "여기 문구 뭘로 하나요?\n뭐가 들어가야 됨????", + timeString = timeDiffString, + painter = rememberAsyncImagePainter(alarm.thumbnail), + read = !alarm.read, + onClick = remember { + { + onClickAlarm(alarm.id) + } + } + ) + } + } + + HorizontalDivider(thickness = 1.dp, color = PokitTheme.colors.borderTertiary) + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/Preview.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/Preview.kt new file mode 100644 index 00000000..9dc24f52 --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/alarmitem/Preview.kt @@ -0,0 +1,20 @@ +package pokitmons.pokit.alarm.components.alarmitem + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import pokitmons.pokit.alarm.model.Alarm +import pokitmons.pokit.alarm.model.Date +import pokitmons.pokit.core.ui.theme.PokitTheme + +@Preview(showBackground = true) +@Composable +internal fun Preview() { + PokitTheme { + Column { + AlarmItem(alarm = Alarm(title = "자연 친화적인 라이프스타일을 위한 무언가"), onClickAlarm = {}, onClickRemove = {}) + AlarmItem(alarm = Alarm(title = "자연 친화적인 라이프스타일을 위한 무언가", read = true, createdAt = Date(year = 1969)), onClickAlarm = {}, onClickRemove = {}) + AlarmItem(alarm = Alarm(title = "자연 친화적인 라이프스타일을 위한 무언가", createdAt = Date(year = 1960)), onClickAlarm = {}, onClickRemove = {}) + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Preview.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Preview.kt new file mode 100644 index 00000000..a9190a55 --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Preview.kt @@ -0,0 +1,18 @@ +package pokitmons.pokit.alarm.components.toolbar + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import pokitmons.pokit.alarm.R +import pokitmons.pokit.core.ui.theme.PokitTheme + +@Preview(showBackground = true) +@Composable +internal fun Preview() { + PokitTheme { + Column { + Toolbar(onClickBack = {}, title = stringResource(id = R.string.alarm_box)) + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Toolbar.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Toolbar.kt new file mode 100644 index 00000000..9d0f6cc7 --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/components/toolbar/Toolbar.kt @@ -0,0 +1,48 @@ +package pokitmons.pokit.alarm.components.toolbar + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.core.ui.R.drawable as coreDrawable + +@Composable +internal fun Toolbar( + onClickBack: () -> Unit, + title: String, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 12.dp) + ) { + IconButton( + modifier = Modifier + .size(48.dp) + .align(Alignment.CenterStart), + onClick = onClickBack + ) { + Icon( + painter = painterResource(id = coreDrawable.icon_24_arrow_left), + contentDescription = "back button" + ) + } + + Text( + modifier = Modifier.align(Alignment.Center), + text = title, + style = PokitTheme.typography.title3.copy(color = PokitTheme.colors.textPrimary) + ) + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Alarm.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Alarm.kt new file mode 100644 index 00000000..502379de --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Alarm.kt @@ -0,0 +1,24 @@ +package pokitmons.pokit.alarm.model + +import pokitmons.pokit.domain.model.alert.Alarm as DomainAlarm + +data class Alarm( + val id: String = "", + val contentId: String = "", + val title: String = "", + val thumbnail: String? = null, + val createdAt: Date = Date(), + val read: Boolean = false, +) { + companion object { + fun fromDomainAlarm(domainAlarm: DomainAlarm): Alarm { + return Alarm( + id = domainAlarm.id.toString(), + contentId = domainAlarm.contentId.toString(), + title = domainAlarm.title, + thumbnail = domainAlarm.thumbnail, + createdAt = Date.getInstanceFromString(domainAlarm.createdAt) + ) + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Date.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Date.kt new file mode 100644 index 00000000..84c540ea --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/model/Date.kt @@ -0,0 +1,59 @@ +package pokitmons.pokit.alarm.model + +import android.icu.util.Calendar +import android.os.Build +import java.text.SimpleDateFormat +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +data class Date( + val year: Int = 2024, + val month: Int = 8, + val day: Int = 17, + val hour: Int = 15, + val minute: Int = 41, +) { + fun getCalendar(): Calendar { + return Calendar.getInstance().apply { + set(Calendar.YEAR, year) + set(Calendar.MONTH, month - 1) + set(Calendar.DAY_OF_MONTH, day) + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minute) + } + } + + companion object { + fun getInstanceFromString( + createdAtString: String, + pattern: String = "yyyy-MM-dd HH:mm:ss", + ): Date { + try { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val formatter = DateTimeFormatter.ofPattern(pattern) + val dateTime = LocalDateTime.parse(createdAtString, formatter) + Date( + year = dateTime.year, + month = dateTime.monthValue, + day = dateTime.dayOfMonth, + hour = dateTime.hour, + minute = dateTime.minute + ) + } else { + val date = SimpleDateFormat(pattern, Locale.KOREA).parse(createdAtString) + val calendar = Calendar.getInstance().apply { time = date } + Date( + year = calendar.get(Calendar.YEAR), + month = calendar.get(Calendar.MONTH) + 1, + day = calendar.get(Calendar.DAY_OF_MONTH), + hour = calendar.get(Calendar.HOUR_OF_DAY), + minute = calendar.get(Calendar.MINUTE) + ) + } + } catch (e: Exception) { + return Date(year = 2000, month = 1, day = 1, hour = 0, minute = 0) + } + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/AlarmPaging.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/AlarmPaging.kt new file mode 100644 index 00000000..b2d21c1a --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/AlarmPaging.kt @@ -0,0 +1,125 @@ +package pokitmons.pokit.alarm.paging + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pokitmons.pokit.alarm.model.Alarm +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.alert.GetAlertsUseCase +import kotlin.coroutines.cancellation.CancellationException + +class AlarmPaging( + private val getAlertsUseCase: GetAlertsUseCase, + private val perPage: Int = 10, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val initPage: Int = 0, + private val firstRequestPage: Int = 3, +) : SimplePaging { + private val _pagingData: MutableStateFlow> = MutableStateFlow(emptyList()) + override val pagingData: StateFlow> = _pagingData.asStateFlow() + + private val _pagingState: MutableStateFlow = MutableStateFlow(SimplePagingState.IDLE) + override val pagingState: StateFlow = _pagingState.asStateFlow() + + private var currentPageIndex = initPage + private var requestJob: Job? = null + + override suspend fun refresh() { + requestJob?.cancel() + + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.LOADING_INIT } + requestJob = coroutineScope.launch { + try { + currentPageIndex = initPage + val response = getAlertsUseCase.getAlerts(size = perPage * firstRequestPage, page = currentPageIndex) + when (response) { + is PokitResult.Success -> { + val alarms = response.result.map { domainAlarm -> + Alarm.fromDomainAlarm(domainAlarm) + } + applyResponse(alarms, firstRequestPage) + } + + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } + } + + override suspend fun load() { + if (pagingState.value != SimplePagingState.IDLE) return + + requestJob?.cancel() + _pagingState.update { SimplePagingState.LOADING_NEXT } + + requestJob = coroutineScope.launch { + try { + val response = getAlertsUseCase.getAlerts(size = perPage, page = currentPageIndex) + when (response) { + is PokitResult.Success -> { + val alarms = response.result.map { domainAlarm -> + Alarm.fromDomainAlarm(domainAlarm) + } + applyResponse(alarms) + } + + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_NEXT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_NEXT } + } + } + } + } + + private fun applyResponse(dataInResponse: List, multiple: Int = 1) { + if (dataInResponse.size < perPage * multiple) { + _pagingState.update { SimplePagingState.LAST } + } else { + _pagingState.update { SimplePagingState.IDLE } + } + _pagingData.update { _pagingData.value + dataInResponse } + currentPageIndex += multiple + } + + override fun clear() { + requestJob?.cancel() + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.IDLE } + } + + override suspend fun deleteItem(item: Alarm) { + val capturedDataList = _pagingData.value + _pagingData.update { capturedDataList.filter { it.id != item.id } } + } + + override suspend fun modifyItem(item: Alarm) { + val capturedDataList = _pagingData.value + val targetData = capturedDataList.find { it.id == item.id } ?: return + + _pagingData.update { + capturedDataList.map { data -> + if (targetData.id == data.id) { + item + } else { + data + } + } + } + } +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePaging.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePaging.kt new file mode 100644 index 00000000..8062d91c --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePaging.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.alarm.paging + +import kotlinx.coroutines.flow.Flow + +interface SimplePaging { + val pagingData: Flow> + suspend fun refresh() + suspend fun load() + val pagingState: Flow + suspend fun modifyItem(item: T) + suspend fun deleteItem(item: T) + fun clear() +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePagingState.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePagingState.kt new file mode 100644 index 00000000..29aeb08e --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/paging/SimplePagingState.kt @@ -0,0 +1,5 @@ +package pokitmons.pokit.alarm.paging + +enum class SimplePagingState { + IDLE, LOADING_NEXT, LOADING_INIT, FAILURE_NEXT, FAILURE_INIT, LAST +} diff --git a/feature/alarm/src/main/java/pokitmons/pokit/alarm/util/DateStringUtil.kt b/feature/alarm/src/main/java/pokitmons/pokit/alarm/util/DateStringUtil.kt new file mode 100644 index 00000000..aaabd291 --- /dev/null +++ b/feature/alarm/src/main/java/pokitmons/pokit/alarm/util/DateStringUtil.kt @@ -0,0 +1,36 @@ +package pokitmons.pokit.alarm.util + +import android.icu.util.Calendar +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import pokitmons.pokit.alarm.R + +private const val HOUR_SECOND = 60 * 60 +private const val DAY_SECOND = 24 * HOUR_SECOND +private const val MONTH_SECOND = 30 * DAY_SECOND +private const val YEAR_SECOND = 365 * DAY_SECOND + +@Composable +fun diffString(createdAtCalendar: Calendar, currentCalendar: Calendar = Calendar.getInstance()): String { + val diffTimeSecond = (currentCalendar.timeInMillis - createdAtCalendar.timeInMillis) / 1000 + return when { + (diffTimeSecond < 60) -> { + stringResource(id = R.string.just_now) + } + (diffTimeSecond < HOUR_SECOND) -> { + stringResource(id = R.string.minute_before, (diffTimeSecond / 60)) + } + (diffTimeSecond < DAY_SECOND) -> { + stringResource(id = R.string.hour_before, (diffTimeSecond / HOUR_SECOND)) + } + (diffTimeSecond < MONTH_SECOND) -> { + stringResource(id = R.string.day_before, (diffTimeSecond / DAY_SECOND)) + } + (diffTimeSecond < YEAR_SECOND) -> { + stringResource(id = R.string.month_before, (diffTimeSecond / MONTH_SECOND)) + } + else -> { + stringResource(id = R.string.year_before, (diffTimeSecond / YEAR_SECOND)) + } + } +} diff --git a/feature/alarm/src/main/res/values/string.xml b/feature/alarm/src/main/res/values/string.xml new file mode 100644 index 00000000..c985f0e2 --- /dev/null +++ b/feature/alarm/src/main/res/values/string.xml @@ -0,0 +1,12 @@ + + + 알림함 + 삭제 + + 지금 막 + %d분 전 + %시간 전 + %d일 전 + %d달 전 + %d년 전 + \ No newline at end of file diff --git a/feature/alarm/src/test/java/pokitmons/pokit/alarm/ExampleUnitTest.kt b/feature/alarm/src/test/java/pokitmons/pokit/alarm/ExampleUnitTest.kt new file mode 100644 index 00000000..3c7f69ab --- /dev/null +++ b/feature/alarm/src/test/java/pokitmons/pokit/alarm/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package pokitmons.pokit.alarm + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a6f9d425..f0c4e192 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,4 +31,5 @@ include(":feature:login") include(":feature:pokitdetail") include(":feature:search") include(":feature:settings") +include(":feature:alarm") include(":feature:home")