diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 409e30a..c098a48 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,16 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.dagger.hilt.android) + alias(libs.plugins.kotlin.ksp) +} + +ksp { + arg(RoomSchemaArgProvider(File(rootProject.projectDir, "room-schemas"))) +} + +hilt { + enableAggregatingTask = false } android { @@ -69,6 +79,13 @@ android { dependencies { + implementation(projects.uiFeatures.locationDetails) + implementation(projects.locationData.cache) + implementation(projects.locationDomain) + implementation(projects.locationData.repository) + implementation(projects.core) + implementation(projects.locationUikit) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -77,6 +94,16 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.material3) + + implementation(libs.retrofit) + implementation(libs.androidx.room.ktx) + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit.converter.kotlinx.serialization) + + implementation(libs.dagger.hilt.android) + ksp(libs.dagger.hilt.compiler) + ksp(libs.androidx.room.compiler) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 634677d..c73411f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + diff --git a/app/src/main/java/com/github/yuriisurzhykov/purs/PursApplication.kt b/app/src/main/java/com/github/yuriisurzhykov/purs/PursApplication.kt new file mode 100644 index 0000000..968ce19 --- /dev/null +++ b/app/src/main/java/com/github/yuriisurzhykov/purs/PursApplication.kt @@ -0,0 +1,7 @@ +package com.github.yuriisurzhykov.purs + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PursApplication : Application() \ No newline at end of file diff --git a/app/src/main/java/com/github/yuriisurzhykov/purs/data/PursDatabase.kt b/app/src/main/java/com/github/yuriisurzhykov/purs/data/PursDatabase.kt new file mode 100644 index 0000000..f420ac8 --- /dev/null +++ b/app/src/main/java/com/github/yuriisurzhykov/purs/data/PursDatabase.kt @@ -0,0 +1,17 @@ +package com.github.yuriisurzhykov.purs.data + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.github.yuriisurzhykov.purs.data.cache.LocationDao +import com.github.yuriisurzhykov.purs.data.cache.model.LocationCache +import com.github.yuriisurzhykov.purs.data.cache.model.WorkingHourCache + +@Database( + entities = [LocationCache::class, WorkingHourCache::class], + version = 1, + exportSchema = true +) +abstract class PursDatabase : RoomDatabase() { + + abstract fun locationDao(): LocationDao +} \ No newline at end of file diff --git a/app/src/main/java/com/github/yuriisurzhykov/purs/di/PursAppModule.kt b/app/src/main/java/com/github/yuriisurzhykov/purs/di/PursAppModule.kt new file mode 100644 index 0000000..ea08b01 --- /dev/null +++ b/app/src/main/java/com/github/yuriisurzhykov/purs/di/PursAppModule.kt @@ -0,0 +1,202 @@ +package com.github.yuriisurzhykov.purs.di + +import android.content.Context +import androidx.room.Room +import com.github.yuriisurzhykov.purs.core.MergeStrategy +import com.github.yuriisurzhykov.purs.core.RequestResponseMergeStrategy +import com.github.yuriisurzhykov.purs.core.RequestResult +import com.github.yuriisurzhykov.purs.data.PursDatabase +import com.github.yuriisurzhykov.purs.data.cache.GetLocationId +import com.github.yuriisurzhykov.purs.data.cache.LocationCacheDataSource +import com.github.yuriisurzhykov.purs.data.cache.LocationDao +import com.github.yuriisurzhykov.purs.data.cache.model.LocationWithWorkingHours +import com.github.yuriisurzhykov.purs.data.cloud.LocationCloudDataSource +import com.github.yuriisurzhykov.purs.data.cloud.LocationService +import com.github.yuriisurzhykov.purs.data.repository.LocationCloudMapper +import com.github.yuriisurzhykov.purs.data.repository.LocationCloudToCacheMapper +import com.github.yuriisurzhykov.purs.data.repository.LocationRepository +import com.github.yuriisurzhykov.purs.data.repository.LocationWorkingHoursCloudMapper +import com.github.yuriisurzhykov.purs.domain.chain.AddMissingDaysUseCase +import com.github.yuriisurzhykov.purs.domain.chain.MergeCrossDayTimeSlotsUseCase +import com.github.yuriisurzhykov.purs.domain.chain.MergeTimeSlotsUseCase +import com.github.yuriisurzhykov.purs.domain.chain.SortWorkingDaysUseCase +import com.github.yuriisurzhykov.purs.domain.chain.WorkingHourChainProcessor +import com.github.yuriisurzhykov.purs.domain.mapper.LocationCacheToDomainMapper +import com.github.yuriisurzhykov.purs.domain.mapper.StringToDayOfWeekMapper +import com.github.yuriisurzhykov.purs.domain.mapper.StringToLocalTimeMapper +import com.github.yuriisurzhykov.purs.domain.mapper.WorkingHourCacheToDomainMapper +import com.github.yuriisurzhykov.purs.domain.usecase.BuildCurrentLocationStatusUseCase +import com.github.yuriisurzhykov.purs.domain.usecase.BuildWorkingHoursListUseCase +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Converter +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PursAppModule { + + @Provides + @Singleton + fun provideDatabase( + @ApplicationContext context: Context + ): PursDatabase = Room.databaseBuilder(context, PursDatabase::class.java, "purs.db").build() + + @Provides + @Singleton + fun provideLocationsDao(database: PursDatabase): LocationDao = database.locationDao() + + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + converterFactory: Converter.Factory + ): Retrofit = + Retrofit.Builder() + .client(okHttpClient) + .addConverterFactory(converterFactory) + .baseUrl("https://purs-demo-bucket-test.s3.us-west-2.amazonaws.com") + .build() + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + @Provides + @Singleton + fun provideConverterFactory(): Converter.Factory = + Json.asConverterFactory("application/json; charset=UTF8".toMediaType()) + + + @Provides + @Singleton + fun provideBuildWorkingHoursListUseCase( + repository: LocationRepository, + mapper: LocationCacheToDomainMapper, + processor: WorkingHourChainProcessor, + buildLocationStatus: BuildCurrentLocationStatusUseCase + ): BuildWorkingHoursListUseCase = BuildWorkingHoursListUseCase.Base( + repository, + mapper, + processor, + buildLocationStatus + ) + + @Provides + @Singleton + fun provideWorkingHourChainProcessor( + mergeCrossDayTimeSlots: MergeCrossDayTimeSlotsUseCase, + addMissingDaysUseCase: AddMissingDaysUseCase, + sortWorkingDaysUseCase: SortWorkingDaysUseCase + ): WorkingHourChainProcessor = WorkingHourChainProcessor.Base( + mergeCrossDayTimeSlots, + addMissingDaysUseCase, + sortWorkingDaysUseCase + ) + + @Provides + @Singleton + fun provideMergeTimeSlotsUseCase(): MergeTimeSlotsUseCase = MergeTimeSlotsUseCase.Base() + + @Provides + @Singleton + fun provideAddMissingDaysUseCase(): AddMissingDaysUseCase = AddMissingDaysUseCase.Base() + + @Provides + @Singleton + fun provideMergeCrossDayTimeSlotsUseCase(sortWorkingDaysUseCase: SortWorkingDaysUseCase): MergeCrossDayTimeSlotsUseCase = + MergeCrossDayTimeSlotsUseCase.Base(sortWorkingDaysUseCase) + + @Provides + @Singleton + fun provideSortMissingDaysUseCase(): SortWorkingDaysUseCase = SortWorkingDaysUseCase.Base() + + @Provides + @Singleton + fun provideLocationCacheToDomainMapper( + mapper: WorkingHourCacheToDomainMapper + ): LocationCacheToDomainMapper = + LocationCacheToDomainMapper.Base(mapper) + + @Provides + @Singleton + fun provideBuildCurrentLocationStatusUseCase(): BuildCurrentLocationStatusUseCase = + BuildCurrentLocationStatusUseCase.Base() + + @Provides + @Singleton + fun provideWorkingHourCacheToDomainMapper( + mapper: StringToLocalTimeMapper, + mergeTimeSlotsUseCase: MergeTimeSlotsUseCase, + stringToDayMapper: StringToDayOfWeekMapper + ): WorkingHourCacheToDomainMapper = + WorkingHourCacheToDomainMapper.Base(mapper, mergeTimeSlotsUseCase, stringToDayMapper) + + @Provides + @Singleton + fun provideStringToDayOfWeekMapper(): StringToDayOfWeekMapper = StringToDayOfWeekMapper.Base() + + @Provides + @Singleton + fun provideStringToLocalTimeMapper(): StringToLocalTimeMapper = StringToLocalTimeMapper.Base() + + @Provides + @Singleton + fun provideLocationRepository( + cloudDataSource: LocationCloudDataSource, + cacheDataSource: LocationCacheDataSource, + sourceMergeStrategy: MergeStrategy>, + mapper: LocationCloudToCacheMapper + ): LocationRepository = LocationRepository.Base( + cloudDataSource, + cacheDataSource, + sourceMergeStrategy, + mapper + ) + + @Provides + @Singleton + fun provideLocationCloudToCacheMapper( + cloudMapper: LocationCloudMapper, + workingHoursCloudMapper: LocationWorkingHoursCloudMapper + ): LocationCloudToCacheMapper = + LocationCloudToCacheMapper.Base(cloudMapper, workingHoursCloudMapper) + + @Provides + @Singleton + fun provideLocationCloudDataSource(locationService: LocationService): LocationCloudDataSource = + LocationCloudDataSource.Base(locationService) + + @Provides + @Singleton + fun provideLocationCacheDataSource( + locationDao: LocationDao, + getLocationId: GetLocationId + ): LocationCacheDataSource = LocationCacheDataSource.Base(locationDao, getLocationId) + + @Provides + @Singleton + fun provideGetLocationId(): GetLocationId = GetLocationId.Const(1) + + @Provides + @Singleton + fun provideMergeStrategy(): MergeStrategy> = + RequestResponseMergeStrategy() + + @Provides + @Singleton + fun provideLocationService(retrofit: Retrofit): LocationService = + retrofit.create(LocationService::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/yuriisurzhykov/purs/MainActivity.kt b/app/src/main/java/com/github/yuriisurzhykov/purs/ui/MainActivity.kt similarity index 50% rename from app/src/main/java/com/github/yuriisurzhykov/purs/MainActivity.kt rename to app/src/main/java/com/github/yuriisurzhykov/purs/ui/MainActivity.kt index c5ed358..9e8c7ff 100644 --- a/app/src/main/java/com/github/yuriisurzhykov/purs/MainActivity.kt +++ b/app/src/main/java/com/github/yuriisurzhykov/purs/ui/MainActivity.kt @@ -1,4 +1,4 @@ -package com.github.yuriisurzhykov.purs +package com.github.yuriisurzhykov.purs.ui import android.os.Bundle import androidx.activity.ComponentActivity @@ -7,12 +7,12 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.github.yuriisurzhykov.purs.ui.theme.PursTestTheme +import com.github.yuriisurzhykov.purs.location.details.LocationDetails +import com.github.yuriisurzhykov.purs.location.uikit.theme.PursTestTheme +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -20,28 +20,9 @@ class MainActivity : ComponentActivity() { setContent { PursTestTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + LocationDetails(modifier = Modifier.padding(innerPadding)) } } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - PursTestTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/ProjectProperties.kt b/buildSrc/src/main/kotlin/ProjectProperties.kt index 51af644..2006c67 100644 --- a/buildSrc/src/main/kotlin/ProjectProperties.kt +++ b/buildSrc/src/main/kotlin/ProjectProperties.kt @@ -6,7 +6,7 @@ object ProjectProperties { val javaTargetCompatibility = JavaVersion.VERSION_19 const val kotlinJvmTarget = "19" - const val minSdkVersion = 24 + const val minSdkVersion = 26 const val targetSdkVersion = 34 const val compileSdkVersion = 34 } \ No newline at end of file diff --git a/core/src/main/java/com/github/yuriisurzhykov/purs/core/MergeStrategy.kt b/core/src/main/java/com/github/yuriisurzhykov/purs/core/MergeStrategy.kt index 7a5bb6f..1e91172 100644 --- a/core/src/main/java/com/github/yuriisurzhykov/purs/core/MergeStrategy.kt +++ b/core/src/main/java/com/github/yuriisurzhykov/purs/core/MergeStrategy.kt @@ -1,10 +1,13 @@ package com.github.yuriisurzhykov.purs.core interface MergeStrategy { + // right is cache, left is cloud fun merge(right: E, left: E): E } -internal class RequestResponseMergeStrategy : MergeStrategy> { +class RequestResponseMergeStrategy : MergeStrategy> { + + // right is cache, left is cloud override fun merge( right: RequestResult, left: RequestResult diff --git a/location-data/cache/build.gradle.kts b/location-data/cache/build.gradle.kts index 85f7b03..ac0e46b 100644 --- a/location-data/cache/build.gradle.kts +++ b/location-data/cache/build.gradle.kts @@ -4,10 +4,6 @@ plugins { alias(libs.plugins.kotlin.ksp) } -ksp { - arg(RoomSchemaArgProvider(File(rootProject.projectDir, "room-schemas"))) -} - android { namespace = "com.github.yuriisurzhykov.purs.data.cache" compileSdk = ProjectProperties.compileSdkVersion diff --git a/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/GetLocationId.kt b/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/GetLocationId.kt index 95b0b0c..2b0e3ad 100644 --- a/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/GetLocationId.kt +++ b/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/GetLocationId.kt @@ -2,6 +2,11 @@ package com.github.yuriisurzhykov.purs.data.cache import javax.inject.Inject +/** + * An interface to provide a location id to persist data. The logic to provide the location if + * moved to this interface because currently we have only one location at the moment, but if + * it will be decided to show more than one location, the new logic will be implemented. + * */ interface GetLocationId { fun locationId(): Long diff --git a/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationCacheDataSource.kt b/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationCacheDataSource.kt index 1f3fcad..c404bfd 100644 --- a/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationCacheDataSource.kt +++ b/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationCacheDataSource.kt @@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import javax.inject.Inject +/** + * Cache data source for location details. + * */ interface LocationCacheDataSource { suspend fun fetchLocation(): Flow> @@ -38,6 +41,7 @@ interface LocationCacheDataSource { } override suspend fun persistLocation(location: LocationWithWorkingHours) { + locationDao.delete(getLocationId.locationId()) val newLocation = location.location.copy(locationId = getLocationId.locationId()) locationDao.insert(location.copy(location = newLocation)) } diff --git a/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationDao.kt b/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationDao.kt index fec3671..fb8560b 100644 --- a/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationDao.kt +++ b/location-data/cache/src/main/java/com/github/yuriisurzhykov/purs/data/cache/LocationDao.kt @@ -22,7 +22,11 @@ abstract class LocationDao { abstract suspend fun insert(workingHour: WorkingHourCache): Long @Transaction - suspend fun insert(location: LocationWithWorkingHours) { + @Query("DELETE FROM locations WHERE locationId=:locationId") + abstract suspend fun delete(locationId: Long) + + @Transaction + open suspend fun insert(location: LocationWithWorkingHours) { val locationId = insert(location.location) location.workingHours.forEach { workingHour -> insert(workingHour.copy(locationId = locationId)) diff --git a/location-data/cache/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cache/LocationDaoTest.kt b/location-data/cache/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cache/LocationDaoTest.kt index e6e65e4..389bc40 100644 --- a/location-data/cache/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cache/LocationDaoTest.kt +++ b/location-data/cache/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cache/LocationDaoTest.kt @@ -41,5 +41,7 @@ class LocationDaoTest { persistedWorkingHour = workingHour return workingHour.workingHourId } + + override suspend fun delete(locationId: Long) = Unit } } \ No newline at end of file diff --git a/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSource.kt b/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSource.kt index 646764c..13bdae3 100644 --- a/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSource.kt +++ b/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSource.kt @@ -16,9 +16,13 @@ interface LocationCloudDataSource { ) : LocationCloudDataSource { override suspend fun fetchLocation(): Flow> { return flow { + // Start progress + emit(RequestResult.InProgress()) try { + // Fetch location details from the cloud val locationResponse = locationService.fetchLocation() if (locationResponse.isSuccessful) { + // Map body to whether success response or error if body is empty or null val responseBody = locationResponse.body() if (responseBody != null) { emit(RequestResult.Success(responseBody)) @@ -26,6 +30,7 @@ interface LocationCloudDataSource { emit(RequestResult.Error(data = null, error = null)) } } else { + // if response is not successful then map error to error response val error = ServerError( locationResponse.code(), locationResponse.errorBody()?.string().orEmpty() diff --git a/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationService.kt b/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationService.kt index 46c211c..c486de8 100644 --- a/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationService.kt +++ b/location-data/cloud/src/main/java/com/github/yuriisurzhykov/purs/data/cloud/LocationService.kt @@ -4,6 +4,9 @@ import com.github.yuriisurzhykov.purs.data.cloud.model.LocationCloud import retrofit2.Response import retrofit2.http.GET +/** + * `LocationService` is a service interface to talk to the cloud to fetch the location data. + * */ interface LocationService { // https://purs-demo-bucket-test.s3.us-west-2.amazonaws.com/location.json diff --git a/location-data/cloud/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSourceTest.kt b/location-data/cloud/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSourceTest.kt index 45de83c..1886635 100644 --- a/location-data/cloud/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSourceTest.kt +++ b/location-data/cloud/src/test/kotlin/com/github/yuriisurzhykov/purs/data/cloud/LocationCloudDataSourceTest.kt @@ -4,7 +4,7 @@ import com.github.yuriisurzhykov.purs.core.RequestResult import com.github.yuriisurzhykov.purs.data.cloud.model.LocationCloud import io.mockk.coEvery import io.mockk.mockk -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaType import okhttp3.ResponseBody.Companion.toResponseBody @@ -22,8 +22,8 @@ class LocationCloudDataSourceTest { coEvery { locationService.fetchLocation() } returns Response.success(mockLocationCloud) val dataSource = LocationCloudDataSource.Base(locationService) - val actual = dataSource.fetchLocation().first() - val expected = RequestResult.Success(mockLocationCloud) + val actual = dataSource.fetchLocation().toList() + val expected = listOf(RequestResult.InProgress(), RequestResult.Success(mockLocationCloud)) assertEquals(expected, actual) } @@ -34,8 +34,8 @@ class LocationCloudDataSourceTest { coEvery { locationService.fetchLocation() } returns Response.success(null) val dataSource = LocationCloudDataSource.Base(locationService) - val actual = dataSource.fetchLocation().first() - val expected = RequestResult.Error(null, null) + val actual = dataSource.fetchLocation().toList() + val expected = listOf(RequestResult.InProgress(), RequestResult.Error(null, null)) assertEquals(expected, actual) } @@ -49,8 +49,11 @@ class LocationCloudDataSourceTest { ) val dataSource = LocationCloudDataSource.Base(locationService) - val actual = dataSource.fetchLocation().first() - val expected = RequestResult.Error(null, ServerError(404, "Page not found")) + val actual = dataSource.fetchLocation().toList() + val expected = listOf( + RequestResult.InProgress(), + RequestResult.Error(null, ServerError(404, "Page not found")) + ) assertEquals(expected, actual) } @@ -62,8 +65,9 @@ class LocationCloudDataSourceTest { coEvery { locationService.fetchLocation() } throws exceptionToThrow val dataSource = LocationCloudDataSource.Base(locationService) - val actual = dataSource.fetchLocation().first() - val expected = RequestResult.Error(null, exceptionToThrow) + val actual = dataSource.fetchLocation().toList() + val expected = + listOf(RequestResult.InProgress(), RequestResult.Error(null, exceptionToThrow)) assertEquals(expected, actual) } diff --git a/location-data/repository/src/main/java/com/github/yuriisurzhykov/purs/data/repository/LocationRepository.kt b/location-data/repository/src/main/java/com/github/yuriisurzhykov/purs/data/repository/LocationRepository.kt index 58a017d..a1800ce 100644 --- a/location-data/repository/src/main/java/com/github/yuriisurzhykov/purs/data/repository/LocationRepository.kt +++ b/location-data/repository/src/main/java/com/github/yuriisurzhykov/purs/data/repository/LocationRepository.kt @@ -8,9 +8,7 @@ import com.github.yuriisurzhykov.purs.data.cache.model.LocationWithWorkingHours import com.github.yuriisurzhykov.purs.data.cloud.LocationCloudDataSource import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import javax.inject.Inject @@ -35,15 +33,11 @@ interface LocationRepository { response.map { cacheDataSource.persistLocation(it) } } - // Produce initial state that is loading - val initial = - flowOf>(RequestResult.InProgress()) - // Combine two data sources together with the strategy based on cloud and cache results val combinedSources = cachedResponse.combine(cloudResponse) { cache, cloud -> sourceMergeStrategy.merge(cache, cloud) } - return merge(initial, combinedSources) + return combinedSources } } } \ No newline at end of file diff --git a/location-domain/build.gradle.kts b/location-domain/build.gradle.kts index 45cf044..a0fe55a 100644 --- a/location-domain/build.gradle.kts +++ b/location-domain/build.gradle.kts @@ -34,8 +34,10 @@ android { dependencies { - implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.coroutines.core) implementation(projects.locationData.repository) + implementation(libs.javax.inject) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/AddMissingDaysUseCase.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/AddMissingDaysUseCase.kt new file mode 100644 index 0000000..c33405b --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/AddMissingDaysUseCase.kt @@ -0,0 +1,25 @@ +package com.github.yuriisurzhykov.purs.domain.chain + +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import java.time.DayOfWeek +import javax.inject.Inject + +/** + * In the result list of working days, some days may be missing. So this interface + * appends all missing days to the collection and returns the result. + * */ +interface AddMissingDaysUseCase : ProcessWorkingHoursCollection { + + class Base @Inject constructor() : AddMissingDaysUseCase { + override fun process(collection: Collection): Collection { + val daysOfWeek = listOf("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN") + val existingDays = collection.map { it.dayName }.toSet() + + val missingDays = daysOfWeek.filter { it !in existingDays }.map { + WorkingDay(it, DayOfWeek.of(daysOfWeek.indexOf(it) + 1), emptySet()) + } + + return collection + missingDays + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/MergeCrossDayTimeSlotsUseCase.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/MergeCrossDayTimeSlotsUseCase.kt new file mode 100644 index 0000000..8bd3bf7 --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/MergeCrossDayTimeSlotsUseCase.kt @@ -0,0 +1,69 @@ +package com.github.yuriisurzhykov.purs.domain.chain + +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import java.time.LocalTime +import javax.inject.Inject + +/** + * The chain processor for merging cross-day time slots. This class is responsible for merging + * working days that contain time slots that cross days. For example, if the starting time of + * the first time slot is 20:00 and the ending time is 24:00 but the starting time of next day + * is 00:00 and the ending time is 04:00, then the first time slot will be merged with the + * next day's time slot. So the merged time slots will be: 20:00-04:00. And will be removed from + * the next schedule day. + * */ +interface MergeCrossDayTimeSlotsUseCase : ProcessWorkingHoursCollection { + + class Base @Inject constructor( + private val sortWorkingDaysUseCase: SortWorkingDaysUseCase + ) : MergeCrossDayTimeSlotsUseCase { + + override fun process(collection: Collection): Collection { + if (collection.isEmpty() || collection.size == 1) return collection + + val mergedTimeSlots = mutableSetOf() + val sortedTimeSlots = sortWorkingDaysUseCase.process(collection).iterator() + + // Get the first time slot + var currentSlot = sortedTimeSlots.next() + + // Iterate through the sorted time slots and merge cross-day time slots + while (sortedTimeSlots.hasNext()) { + val nextSlot = sortedTimeSlots.next() + val lastCurrentTimeSlot = currentSlot.scheduleList.last() + val firstNextTimeSlot = nextSlot.scheduleList.first() + + // Merge the current time slot with the next time slot if they are on different days + // and the last current time slot ends at midnight and the first next time slot starts at midnight + if (currentSlot.dayName != nextSlot.dayName && + lastCurrentTimeSlot.endTime == LocalTime.MIDNIGHT && + firstNextTimeSlot.startTime == LocalTime.MIDNIGHT && + !lastCurrentTimeSlot.isOpen24H() + ) { + val timeSlotSize = currentSlot.scheduleList.size + // Update the working set of the current time slot to exclude the last time slot + val updatedWorkingSet = + currentSlot.scheduleList.take(timeSlotSize - 1).toMutableSet() + // Add the next time slot to the working set + updatedWorkingSet.add( + lastCurrentTimeSlot.copy( + startTime = lastCurrentTimeSlot.startTime, + endTime = firstNextTimeSlot.endTime + ) + ) + // Add the merged time slot to the merged time slots + mergedTimeSlots.add(currentSlot.copy(scheduleList = updatedWorkingSet)) + // Update the current time slot to the next time slot + currentSlot = + nextSlot.copy(scheduleList = nextSlot.scheduleList.drop(1).toSet()) + } else { + // Add the current time slot to the merged time slots + mergedTimeSlots.add(currentSlot) + currentSlot = nextSlot + } + } + mergedTimeSlots.add(currentSlot) + return mergedTimeSlots + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/MergeTimeSlotsUseCase.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/MergeTimeSlotsUseCase.kt new file mode 100644 index 0000000..15172ac --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/MergeTimeSlotsUseCase.kt @@ -0,0 +1,52 @@ +package com.github.yuriisurzhykov.purs.domain.chain + +import com.github.yuriisurzhykov.purs.domain.model.TimeSlot +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import javax.inject.Inject + +/** + * This interface defines a use case for merging time slots in a collection of working days that + * overlap each other. For example, time slots: 8:00-15:00 and 10:00-11:00 are considered overlapping + * time slots. To this interface merges all such time slots into one time slot: 8:00-15:00. + * */ +interface MergeTimeSlotsUseCase { + + fun mergeTimeSlots(workDay: WorkingDay): WorkingDay + + class Base @Inject constructor() : MergeTimeSlotsUseCase { + override fun mergeTimeSlots(workDay: WorkingDay): WorkingDay { + val timeSlots = workDay.scheduleList + if (timeSlots.isEmpty()) return workDay + + // Sort time intervals by day and start time + val sortedTimeSlots = timeSlots.sortedWith(compareBy { it.startTime }) + + val mergedTimeSlots = mutableSetOf() + + var currentSlot = sortedTimeSlots[0] + + for (i in 1 until sortedTimeSlots.size) { + val nextSlot = sortedTimeSlots[i] + + // Check if current and the next time intervals are intersect + if (currentSlot.endTime >= nextSlot.startTime) { + // Merge intervals + currentSlot = TimeSlot( + startTime = currentSlot.startTime, + endTime = maxOf(currentSlot.endTime, nextSlot.endTime) + ) + } else { + // Add current interval to the list of merged intervals + mergedTimeSlots.add(currentSlot) + // Switch to the next interval + currentSlot = nextSlot + } + } + + // Add the last interval + mergedTimeSlots.add(currentSlot) + + return workDay.copy(scheduleList = mergedTimeSlots) + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/ProcessWorkingHoursCollection.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/ProcessWorkingHoursCollection.kt new file mode 100644 index 0000000..4f1b54f --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/ProcessWorkingHoursCollection.kt @@ -0,0 +1,11 @@ +package com.github.yuriisurzhykov.purs.domain.chain + +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay + +/** + * Chain processor for the list of working hours. + * */ +interface ProcessWorkingHoursCollection { + + fun process(collection: Collection): Collection +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/SortWorkingDaysUseCase.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/SortWorkingDaysUseCase.kt new file mode 100644 index 0000000..63d1d2a --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/SortWorkingDaysUseCase.kt @@ -0,0 +1,22 @@ +package com.github.yuriisurzhykov.purs.domain.chain + +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import javax.inject.Inject + +/** + * Chain processor for the list of working hours collection. It sorts all working days by day of week + * based on the day name and then by start time. + * */ +interface SortWorkingDaysUseCase : ProcessWorkingHoursCollection { + + class Base @Inject constructor() : SortWorkingDaysUseCase { + override fun process(collection: Collection): Collection { + val daysOfWeek = listOf("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN") + val dayToIndex = daysOfWeek.withIndex().associate { it.value to it.index } + return collection.map { workingHour -> + workingHour.scheduleList.sortedWith(compareBy { it.startTime }) + workingHour + }.sortedWith(compareBy { dayToIndex[it.dayName] }) + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/WorkingHourChainProcessor.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/WorkingHourChainProcessor.kt new file mode 100644 index 0000000..12e0e5c --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/chain/WorkingHourChainProcessor.kt @@ -0,0 +1,24 @@ +package com.github.yuriisurzhykov.purs.domain.chain + +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import javax.inject.Inject + +/** + * The base interface for processing working hours collection. This is the primary + * class that contains all other processing chains and iterate over the chain to process + * the working hours collection. + * */ +interface WorkingHourChainProcessor : ProcessWorkingHoursCollection { + + class Base @Inject constructor( + private vararg val processors: ProcessWorkingHoursCollection + ) : WorkingHourChainProcessor { + override fun process(collection: Collection): Collection { + var processCollection = collection + processors.forEach { processor -> + processCollection = processor.process(processCollection) + } + return processCollection + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/LocationCacheToDomainMapper.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/LocationCacheToDomainMapper.kt new file mode 100644 index 0000000..bef0e6a --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/LocationCacheToDomainMapper.kt @@ -0,0 +1,22 @@ +package com.github.yuriisurzhykov.purs.domain.mapper + +import com.github.yuriisurzhykov.purs.core.Mapper +import com.github.yuriisurzhykov.purs.data.cache.model.LocationWithWorkingHours +import com.github.yuriisurzhykov.purs.domain.model.Location +import javax.inject.Inject + +interface LocationCacheToDomainMapper : Mapper { + + class Base @Inject constructor( + private val workingHourMapper: WorkingHourCacheToDomainMapper, + ) : LocationCacheToDomainMapper { + override fun map(source: LocationWithWorkingHours): Location { + val workingHours = workingHourMapper.map(source.workingHours) + return Location( + source.location.locationName, + null, + workingHours + ) + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/StringToDayOfWeekMapper.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/StringToDayOfWeekMapper.kt new file mode 100644 index 0000000..d13358e --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/StringToDayOfWeekMapper.kt @@ -0,0 +1,15 @@ +package com.github.yuriisurzhykov.purs.domain.mapper + +import com.github.yuriisurzhykov.purs.core.Mapper +import java.time.DayOfWeek +import javax.inject.Inject + +interface StringToDayOfWeekMapper : Mapper { + + class Base @Inject constructor() : StringToDayOfWeekMapper { + override fun map(source: String): DayOfWeek { + val weekNames = listOf("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN") + return DayOfWeek.of(weekNames.indexOf(source)) + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/StringToLocalTimeMapper.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/StringToLocalTimeMapper.kt new file mode 100644 index 0000000..472907f --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/StringToLocalTimeMapper.kt @@ -0,0 +1,21 @@ +package com.github.yuriisurzhykov.purs.domain.mapper + +import com.github.yuriisurzhykov.purs.core.Mapper +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.ResolverStyle +import javax.inject.Inject + +interface StringToLocalTimeMapper : Mapper { + + abstract class Abstract @Inject constructor( + private val timeFormatter: DateTimeFormatter + ) : StringToLocalTimeMapper { + override fun map(source: String): LocalTime { + return LocalTime.parse(source, timeFormatter) + } + } + + class Base @Inject constructor() : + Abstract(DateTimeFormatter.ISO_LOCAL_TIME.withResolverStyle(ResolverStyle.LENIENT)) +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/WorkingHourCacheToDomainMapper.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/WorkingHourCacheToDomainMapper.kt new file mode 100644 index 0000000..3460671 --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/mapper/WorkingHourCacheToDomainMapper.kt @@ -0,0 +1,37 @@ +package com.github.yuriisurzhykov.purs.domain.mapper + +import com.github.yuriisurzhykov.purs.core.Mapper +import com.github.yuriisurzhykov.purs.data.cache.model.WorkingHourCache +import com.github.yuriisurzhykov.purs.domain.chain.MergeTimeSlotsUseCase +import com.github.yuriisurzhykov.purs.domain.model.TimeSlot +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import javax.inject.Inject + +interface WorkingHourCacheToDomainMapper : + Mapper, Set> { + + class Base @Inject constructor( + private val timeMapper: StringToLocalTimeMapper, + private val mergeTimeSlotsUseCase: MergeTimeSlotsUseCase, + private val weekDayMapper: StringToDayOfWeekMapper + ) : WorkingHourCacheToDomainMapper { + override fun map(source: List): Set { + return source + .groupBy { it.workingDayName } + .asSequence() + .map { entry -> + val workingDay = WorkingDay( + entry.key, + weekDayMapper.map(entry.key), + entry.value.map { hourCache -> + TimeSlot( + timeMapper.map(hourCache.workingHourStart), + timeMapper.map(hourCache.workingHourEnd) + ) + }.toSet() + ) + mergeTimeSlotsUseCase.mergeTimeSlots(workingDay) + }.toSet() + } + } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/Location.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/Location.kt new file mode 100644 index 0000000..410af81 --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/Location.kt @@ -0,0 +1,7 @@ +package com.github.yuriisurzhykov.purs.domain.model + +data class Location( + val locationName: String, + val status: LocationStatus?, + val workingDays: Collection +) \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/LocationStatus.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/LocationStatus.kt new file mode 100644 index 0000000..b8e5e92 --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/LocationStatus.kt @@ -0,0 +1,32 @@ +package com.github.yuriisurzhykov.purs.domain.model + +import java.time.LocalTime + +sealed interface LocationStatus { + + data class Open( + val closeTime: LocalTime + ) : LocationStatus + + data class ClosingSoon( + val closeTime: LocalTime, + val reopenTime: LocalTime + ) : LocationStatus + + data class ClosingSoonLongReopen( + val closeTime: LocalTime, + val reopenDay: String, + val reopenTime: LocalTime + ): LocationStatus + + data class ClosedOpenSoon( + val reopenTime: LocalTime + ) : LocationStatus + + data class Closed( + val openTime: LocalTime, + val openDay: String + ) : LocationStatus + + data object ClosedFully : LocationStatus +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/TimeSlot.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/TimeSlot.kt new file mode 100644 index 0000000..714c37c --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/TimeSlot.kt @@ -0,0 +1,10 @@ +package com.github.yuriisurzhykov.purs.domain.model + +import java.time.LocalTime + +data class TimeSlot( + val startTime: LocalTime, + val endTime: LocalTime +) { + fun isOpen24H() = startTime == LocalTime.MIDNIGHT && endTime == LocalTime.MIDNIGHT +} diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/WorkingDay.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/WorkingDay.kt new file mode 100644 index 0000000..36701ad --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/model/WorkingDay.kt @@ -0,0 +1,11 @@ +package com.github.yuriisurzhykov.purs.domain.model + +import java.time.DayOfWeek + +data class WorkingDay( + val dayName: String, + val weekDay: DayOfWeek, + val scheduleList: Set +) { + fun open24H() = scheduleList.any { it.isOpen24H() } +} \ No newline at end of file diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/usecase/BuildCurrentLocationStatusUseCase.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/usecase/BuildCurrentLocationStatusUseCase.kt new file mode 100644 index 0000000..a8c7d87 --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/usecase/BuildCurrentLocationStatusUseCase.kt @@ -0,0 +1,132 @@ +package com.github.yuriisurzhykov.purs.domain.usecase + +import com.github.yuriisurzhykov.purs.domain.model.LocationStatus +import com.github.yuriisurzhykov.purs.domain.model.TimeSlot +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalTime +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +interface BuildCurrentLocationStatusUseCase { + + fun currentStatus(workingDays: Collection): LocationStatus + + class Base @Inject constructor() : BuildCurrentLocationStatusUseCase { + override fun currentStatus(workingDays: Collection): LocationStatus { + return generateStatusMessage(workingDays.toSet()) + } + + private fun generateStatusMessage( + workingDays: Set, + ): LocationStatus { + val currentDate = LocalDate.now() + val currentTime = LocalTime.now() + // Get current schedule for the day + val currentSchedule = workingDays.toList()[currentDate.dayOfWeek.value - 1] + // Get next schedule for the current day or next day + val nextSchedule: Pair? = + findNextWorkingDay(workingDays.toList(), currentDate, currentTime) + + // Return location status for whether it is open or closed now + return findCurrentOpenStatus(currentSchedule, nextSchedule!!, currentTime) + ?: findClosedStatus(currentTime, nextSchedule) + } + + /** + * Returns the location status if the location is closed currently. It looks for next + * schedule and returns [LocationStatus] based if it opens within 24 hours or not. + * */ + private fun findClosedStatus( + currentTime: LocalTime, + nextSchedule: Pair + ): LocationStatus { + val nextOpenTime = nextSchedule.second.startTime + val reopenTimeDifference = currentTime.until(nextOpenTime, ChronoUnit.HOURS) + return if (reopenTimeDifference > 24) { + LocationStatus.Closed(nextOpenTime, nextSchedule.first) + } else { + LocationStatus.ClosedOpenSoon(nextOpenTime) + } + } + + /** + * Returns the location status if the location is open currently. It looks for next + * schedule and returns [LocationStatus] based if it REopens within 24 hours or not. + * */ + private fun findCurrentOpenStatus( + workingDay: WorkingDay, + nextWorkingDay: Pair, + currentTime: LocalTime + ): LocationStatus? { + // Looking for schedule time slot that applied to the current time + val currentOpenSchedule: TimeSlot? = workingDay.scheduleList.find { timeSlot -> + if (timeSlot.endTime < timeSlot.startTime) { + timeSlot.startTime.isBefore(currentTime) + } else { + timeSlot.endTime.isAfter(currentTime) && timeSlot.startTime.isBefore(currentTime) + } + } + return if (currentOpenSchedule != null) { + val timeDifference = + currentTime.until(currentOpenSchedule.endTime, ChronoUnit.MINUTES) + // If the location closes within 24 hours, return the location status + // that it closing soon. Otherwise, return the location status that it opens + return if (timeDifference <= 60) { + val nextOpenTime = nextWorkingDay.second.startTime + val reopenTimeDifference = currentTime.until(nextOpenTime, ChronoUnit.HOURS) + if (reopenTimeDifference > 24) { + LocationStatus.ClosingSoonLongReopen( + currentOpenSchedule.endTime, + nextWorkingDay.first, + nextOpenTime + ) + } else { + LocationStatus.ClosingSoon(currentOpenSchedule.endTime, nextOpenTime) + } + } else { + LocationStatus.Open(currentOpenSchedule.endTime) + } + } else null + } + + private fun findNextWorkingDay( + schedule: List, + currentDate: LocalDate, + currentTime: LocalTime + ): Pair? { + var currentIndex = currentDate.dayOfWeek.value - 1 + var firstLoopRun = true + while (true) { + // Check if we have reached the start point for the work day. Start point means + // that we reached the current day of the week. If we have reached the start point, + // we either returns next time slot or null + if (currentIndex == currentDate.dayOfWeek.value - 1) { + val containsSchedule = + schedule[currentIndex].scheduleList.find { it.startTime.isAfter(currentTime) } + return if (containsSchedule != null) { + Pair(schedule[currentIndex].dayName, containsSchedule) + } else { + if (firstLoopRun) { + firstLoopRun = false + continue + } else { + return null + } + } + } + // If we found any other time schedule that is not empty, return it + if (schedule[currentIndex].scheduleList.isNotEmpty()) { + return Pair( + schedule[currentIndex].dayName, + schedule[currentIndex].scheduleList.first() + ) + } + // Increment by one in range of 0 to 6 + currentIndex = (currentIndex + 1) % DayOfWeek.entries.size + } + } + } +} + diff --git a/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/usecase/BuildWorkingHoursListUseCase.kt b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/usecase/BuildWorkingHoursListUseCase.kt new file mode 100644 index 0000000..7eec8ef --- /dev/null +++ b/location-domain/src/main/java/com/github/yuriisurzhykov/purs/domain/usecase/BuildWorkingHoursListUseCase.kt @@ -0,0 +1,51 @@ +package com.github.yuriisurzhykov.purs.domain.usecase + +import com.github.yuriisurzhykov.purs.core.RequestResult +import com.github.yuriisurzhykov.purs.core.map +import com.github.yuriisurzhykov.purs.data.repository.LocationRepository +import com.github.yuriisurzhykov.purs.domain.chain.WorkingHourChainProcessor +import com.github.yuriisurzhykov.purs.domain.mapper.LocationCacheToDomainMapper +import com.github.yuriisurzhykov.purs.domain.model.Location +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * The use case to build a list of working hours for a location. Operates using flow + * to emit [RequestResult]. + * */ +interface BuildWorkingHoursListUseCase { + + fun workingHours(): Flow> + + class Base @Inject constructor( + private val repository: LocationRepository, + private val mapper: LocationCacheToDomainMapper, + private val processor: WorkingHourChainProcessor, + private val buildLocationStatus: BuildCurrentLocationStatusUseCase + ) : BuildWorkingHoursListUseCase { + + override fun workingHours(): Flow> { + return flow { + val repositoryResult = repository.fetchLocationDetails() + .map { requestResult -> + requestResult + // Map the cache to domain model + .map { cache -> mapper.map(cache) } + // Process the working hours using the chain processor, to process + // all edge cases. + .map { location -> + location.copy(workingDays = processor.process(location.workingDays)) + } + // Get the current status of the location. + .map { location -> + location.copy(status = buildLocationStatus.currentStatus(location.workingDays)) + } + } + emitAll(repositoryResult) + } + } + } +} \ No newline at end of file diff --git a/location-uikit/.gitignore b/location-uikit/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/location-uikit/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/location-uikit/build.gradle.kts b/location-uikit/build.gradle.kts new file mode 100644 index 0000000..a1852d0 --- /dev/null +++ b/location-uikit/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) +} + +android { + namespace = "com.github.yuriisurzhykov.purs.uikit" + compileSdk = ProjectProperties.compileSdkVersion + + defaultConfig { + minSdk = ProjectProperties.minSdkVersion + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = ProjectProperties.javaTargetCompatibility + targetCompatibility = ProjectProperties.javaSourceCompatibility + } + kotlinOptions { + jvmTarget = ProjectProperties.kotlinJvmTarget + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.kotlin.compiler.ext.get() + } +} + + +dependencies { + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.material3) + debugImplementation(libs.androidx.compose.ui.tooling.preview) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/location-uikit/consumer-rules.pro b/location-uikit/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/location-uikit/proguard-rules.pro b/location-uikit/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/location-uikit/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/location-uikit/src/main/AndroidManifest.xml b/location-uikit/src/main/AndroidManifest.xml new file mode 100644 index 0000000..44008a4 --- /dev/null +++ b/location-uikit/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Color.kt b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Color.kt similarity index 61% rename from app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Color.kt rename to location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Color.kt index cfe6bef..3ab61af 100644 --- a/app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Color.kt +++ b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Color.kt @@ -1,11 +1,14 @@ -package com.github.yuriisurzhykov.purs.ui.theme +package com.github.yuriisurzhykov.purs.location.uikit.theme import androidx.compose.ui.graphics.Color + val Purple80 = Color(0xFFD0BCFF) val PurpleGrey80 = Color(0xFFCCC2DC) val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +val PrimaryTextColor = Color(0xFF333333) \ No newline at end of file diff --git a/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Dimensions.kt b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Dimensions.kt new file mode 100644 index 0000000..0154754 --- /dev/null +++ b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Dimensions.kt @@ -0,0 +1,8 @@ +package com.github.yuriisurzhykov.purs.location.uikit.theme + +import androidx.compose.ui.unit.dp + +val TinyPadding = 4.dp +val SmallPadding = 8.dp +val DefaultPadding = 16.dp +val DefaultCornerRadius = 12.dp \ No newline at end of file diff --git a/app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Theme.kt b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Theme.kt similarity index 86% rename from app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Theme.kt rename to location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Theme.kt index 75bc405..a444beb 100644 --- a/app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Theme.kt +++ b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Theme.kt @@ -1,6 +1,5 @@ -package com.github.yuriisurzhykov.purs.ui.theme +package com.github.yuriisurzhykov.purs.location.uikit.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -9,6 +8,7 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( @@ -20,17 +20,16 @@ private val DarkColorScheme = darkColorScheme( private val LightColorScheme = lightColorScheme( primary = Purple40, secondary = PurpleGrey40, - tertiary = Pink40 + tertiary = Pink40, - /* Other default colors to override +// Other default colors to override background = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE), onPrimary = Color.White, onSecondary = Color.White, onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ + onBackground = Color(0x333333), + onSurface = Color(0x333333), ) @Composable diff --git a/app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Type.kt b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Type.kt similarity index 70% rename from app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Type.kt rename to location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Type.kt index 2006d8d..e76280d 100644 --- a/app/src/main/java/com/github/yuriisurzhykov/purs/ui/theme/Type.kt +++ b/location-uikit/src/main/java/com/github/yuriisurzhykov/purs/location/uikit/theme/Type.kt @@ -1,4 +1,4 @@ -package com.github.yuriisurzhykov.purs.ui.theme +package com.github.yuriisurzhykov.purs.location.uikit.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle @@ -8,15 +8,23 @@ import androidx.compose.ui.unit.sp // Set of Material typography styles to start with val Typography = Typography( + bodyMedium = TextStyle( + color = PrimaryTextColor, + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), bodyLarge = TextStyle( + color = PrimaryTextColor, fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp - ) - /* Other default text styles to override + ), titleLarge = TextStyle( + color = PrimaryTextColor, fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 22.sp, @@ -24,11 +32,11 @@ val Typography = Typography( letterSpacing = 0.sp ), labelSmall = TextStyle( + color = PrimaryTextColor, fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ) - */ ) \ No newline at end of file diff --git a/room-schemas/com.github.yuriisurzhykov.purs.data.PursDatabase/1.json b/room-schemas/com.github.yuriisurzhykov.purs.data.PursDatabase/1.json new file mode 100644 index 0000000..81b8921 --- /dev/null +++ b/room-schemas/com.github.yuriisurzhykov.purs.data.PursDatabase/1.json @@ -0,0 +1,96 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "c990cb6f7f01b02cd69b48079b120c66", + "entities": [ + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` INTEGER NOT NULL, `locationName` TEXT NOT NULL, PRIMARY KEY(`locationId`))", + "fields": [ + { + "fieldPath": "locationId", + "columnName": "locationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "locationName", + "columnName": "locationName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "locationId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "working_hours", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workingHourId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `workingHourStart` TEXT NOT NULL, `workingHourEnd` TEXT NOT NULL, `workingDayName` TEXT NOT NULL, `locationId` INTEGER NOT NULL, FOREIGN KEY(`locationId`) REFERENCES `locations`(`locationId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "workingHourId", + "columnName": "workingHourId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workingHourStart", + "columnName": "workingHourStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workingHourEnd", + "columnName": "workingHourEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workingDayName", + "columnName": "workingDayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locationId", + "columnName": "locationId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "workingHourId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "locationId" + ], + "referencedColumns": [ + "locationId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c990cb6f7f01b02cd69b48079b120c66')" + ] + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 2a915c0..f1fffa7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,3 +31,4 @@ include(":location-data:repository") include(":location-domain") include(":ui-features:location-details") include(":core:data") +include(":location-uikit") diff --git a/ui-features/location-details/build.gradle.kts b/ui-features/location-details/build.gradle.kts index 1cce75d..587d000 100644 --- a/ui-features/location-details/build.gradle.kts +++ b/ui-features/location-details/build.gradle.kts @@ -42,6 +42,11 @@ android { dependencies { + implementation(projects.locationDomain) + implementation(projects.locationData.repository) + implementation(projects.core) + implementation(projects.locationUikit) + implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.core.ktx) @@ -50,8 +55,8 @@ dependencies { implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.material3) + debugImplementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) diff --git a/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/ArrowAnimation.kt b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/ArrowAnimation.kt new file mode 100644 index 0000000..a14bbdd --- /dev/null +++ b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/ArrowAnimation.kt @@ -0,0 +1,52 @@ +package com.github.yuriisurzhykov.purs.location.details + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ArrowAnimation() { + val infiniteTransition = rememberInfiniteTransition(label = "") + val offsetY by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 10f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 500, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), label = "" + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = null, + modifier = Modifier + .offset(y = -offsetY.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.surface + ) + Text( + text = "View Menu", + color = MaterialTheme.colorScheme.surface, + style = MaterialTheme.typography.bodyLarge + ) + } +} \ No newline at end of file diff --git a/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocalTimeTextView.kt b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocalTimeTextView.kt new file mode 100644 index 0000000..a9b2a74 --- /dev/null +++ b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocalTimeTextView.kt @@ -0,0 +1,38 @@ +package com.github.yuriisurzhykov.purs.location.details + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import com.github.yuriisurzhykov.purs.domain.model.TimeSlot +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +@Composable +fun LocalTimeTextView( + time: TimeSlot, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodyLarge +) { + Text( + text = stringResource(id = R.string.format_patter_time).format( + time.startTime.toFormattedString(), + time.endTime.toFormattedString() + ), + modifier = modifier, + style = style + ) +} + +internal fun LocalTime.toFormattedString(): String { + return formatTime(this) +} + +private fun formatTime(time: LocalTime): String { + // Format the time using the default locale and the following format: 1PM, 12AM + val format = DateTimeFormatter.ofPattern("ha", Locale.getDefault()) + return time.format(format) +} \ No newline at end of file diff --git a/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocationDetailsScreen.kt b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocationDetailsScreen.kt new file mode 100644 index 0000000..cab4c5b --- /dev/null +++ b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocationDetailsScreen.kt @@ -0,0 +1,386 @@ +package com.github.yuriisurzhykov.purs.location.details + +import android.widget.Toast +import androidx.annotation.ColorRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.github.yuriisurzhykov.purs.domain.model.Location +import com.github.yuriisurzhykov.purs.domain.model.LocationStatus +import com.github.yuriisurzhykov.purs.domain.model.WorkingDay +import com.github.yuriisurzhykov.purs.location.uikit.theme.DefaultCornerRadius +import com.github.yuriisurzhykov.purs.location.uikit.theme.DefaultPadding +import com.github.yuriisurzhykov.purs.location.uikit.theme.SmallPadding +import com.github.yuriisurzhykov.purs.location.uikit.theme.TinyPadding +import java.time.LocalDate +import java.util.Locale + + +@Composable +fun LocationDetails(modifier: Modifier) { + LocationDetails(viewModel = viewModel(), modifier = modifier) +} + +@Composable +internal fun LocationDetails( + viewModel: LocationDetailsViewModel, + modifier: Modifier = Modifier +) { + val state = viewModel.detailsResponse.collectAsState().value + BackgroundImage(modifier = modifier.fillMaxSize()) + if (state != State.None) { + Content(state = state, modifier = modifier.fillMaxSize()) + } +} + +@Composable +internal fun Content(state: State, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + if (state is State.Success && state.location != null) { + LocationDetailsMain(state.location) + } + if (state is State.Loading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(DefaultPadding) + ) + if (state.location != null) { + LocationDetailsMain(location = state.location) + } + } + if (state is State.Error) { + if (state.location != null) { + LocationDetailsMain(location = state.location) + } + state.error?.let { + Toast.makeText(LocalContext.current, it.message.orEmpty(), Toast.LENGTH_SHORT) + .show() + } + } + } +} + +@Composable +internal fun LocationDetailsMain(location: Location) { + Text( + text = location.locationName, + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 54.sp, + color = Color.White, + textAlign = TextAlign.Start + ), + textAlign = TextAlign.Justify, + modifier = Modifier.padding(DefaultPadding) + ) + + Box(modifier = Modifier.fillMaxSize()) { + OperatingHoursBox(location = location) + Box( + modifier = Modifier + .padding(bottom = 16.dp) + .align(Alignment.BottomCenter) + ) { + ArrowAnimation() + } + } +} + +@Composable +internal fun OperatingHoursBox(location: Location, modifier: Modifier = Modifier) { + var expanded by remember { mutableStateOf(false) } + val expandIconAngle = remember { Animatable(0f) } + + LaunchedEffect(key1 = expanded) { + expandIconAngle.animateTo( + targetValue = (expandIconAngle.value + 90) % 180, + animationSpec = tween( + durationMillis = 300, + delayMillis = 0, + easing = LinearOutSlowInEasing + ) + ) + } + + Column( + modifier = modifier + .padding(DefaultPadding) + ) { + Box( + modifier = modifier + .fillMaxWidth() + .background( + colorResource(id = R.color.background_card_semitransprent), + shape = if (expanded) { + RoundedCornerShape( + topStart = DefaultCornerRadius, + topEnd = DefaultCornerRadius + ) + } else { + RoundedCornerShape(DefaultCornerRadius) + } + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple( + radius = DefaultCornerRadius, + color = colorResource(id = R.color.background_card_semitransprent) + ), + onClick = { + expanded = !expanded + }) + .padding(DefaultPadding), + contentAlignment = Alignment.CenterStart + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + location.status?.let { status -> + LocationStatusText( + status, + modifier = Modifier.padding(end = DefaultPadding) + ) + } + Spacer(modifier = Modifier.width(SmallPadding)) + location.status?.let { status -> + LocationStatusBadge(status) + } + } + Icon( + painter = painterResource(id = R.drawable.chevron_right), + contentDescription = null, + modifier = Modifier.rotate(expandIconAngle.value) + ) + } + Text( + text = stringResource(id = R.string.label_see_full_hours), + fontSize = 14.sp, + color = Color.Gray + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .background( + colorResource(id = R.color.background_card_semitransprent), + shape = RoundedCornerShape( + bottomStart = DefaultCornerRadius, + bottomEnd = DefaultCornerRadius + ) + ) + .padding(DefaultPadding) + ) { + HorizontalDivider(color = Color.Gray, thickness = 1.dp) + Spacer(modifier = Modifier.height(TinyPadding)) + LazyColumn { + items(location.workingDays.size) { + val workingHour = location.workingDays.toList()[it] + WorkingHourView(workingHour) + } + } + } + } + } +} + +@Composable +fun LocationStatusBadge(status: LocationStatus, modifier: Modifier = Modifier) { + when (status) { + is LocationStatus.Open -> ColorBadge(color = R.color.color_green, modifier) + + is LocationStatus.ClosingSoon, + is LocationStatus.ClosingSoonLongReopen -> ColorBadge( + color = R.color.color_yellow, + modifier + ) + + is LocationStatus.ClosedOpenSoon -> ColorBadge(color = R.color.color_red, modifier) + is LocationStatus.ClosedFully -> ColorBadge(color = R.color.color_red, modifier) + is LocationStatus.Closed -> ColorBadge(color = R.color.color_red, modifier) + } +} + +@Composable +fun ColorBadge(@ColorRes color: Int, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(8.dp) + .background( + color = colorResource(id = color), + shape = MaterialTheme.shapes.small + ) + ) +} + +@Composable +fun LocationStatusText(status: LocationStatus, modifier: Modifier = Modifier) { + when (status) { + is LocationStatus.Open -> LocationStatusTextView( + text = stringResource(id = R.string.label_location_open).format( + status.closeTime.toFormattedString() + ), + modifier = modifier + ) + + is LocationStatus.ClosingSoon -> LocationStatusTextView( + text = stringResource(id = R.string.label_location_closing_soon).format( + status.closeTime.toFormattedString(), + status.reopenTime.toFormattedString() + ), + modifier = modifier + ) + + is LocationStatus.ClosedOpenSoon -> LocationStatusTextView( + text = stringResource(id = R.string.label_location_closed_opens_soon).format( + status.reopenTime.toFormattedString() + ), + modifier = modifier + ) + + is LocationStatus.Closed -> LocationStatusTextView( + text = stringResource(id = R.string.label_location_closed_opens_then).format( + status.openDay, + status.openTime.toFormattedString() + ), + modifier = modifier + ) + + is LocationStatus.ClosingSoonLongReopen -> LocationStatusTextView( + text = stringResource(id = R.string.label_location_closing_soon_reopen_then).format( + status.closeTime.toFormattedString(), + status.reopenDay, + status.reopenTime.toFormattedString() + ), + modifier = modifier + ) + + is LocationStatus.ClosedFully -> LocationStatusTextView( + text = stringResource(id = R.string.label_location_closed), + modifier = modifier + ) + } +} + +@Composable +fun LocationStatusTextView(text: String, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = text, + style = MaterialTheme.typography.bodyLarge + ) +} + +@Composable +fun BackgroundImage(modifier: Modifier = Modifier) { + AsyncImage( + model = R.drawable.screen_background, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + ) +} + +@Composable +fun WorkingHourView(workingDay: WorkingDay) { + val textWeight = if (workingDay.weekDay == LocalDate.now().dayOfWeek) { + FontWeight.Bold + } else { + FontWeight.Normal + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(TinyPadding), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = workingDay.weekDay.getDisplayName( + java.time.format.TextStyle.FULL, + Locale.getDefault() + ), style = MaterialTheme.typography.bodyLarge.copy(fontWeight = textWeight) + ) + Column { + if (workingDay.scheduleList.isEmpty()) { + Text( + text = stringResource(id = R.string.label_location_closed), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = textWeight) + ) + } else if (workingDay.open24H()) { + Text( + text = stringResource(id = R.string.label_location_open_24h), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = textWeight) + ) + } else { + workingDay.scheduleList.forEach { timeSlot -> + LocalTimeTextView( + timeSlot, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = textWeight) + ) + } + } + } + } +} \ No newline at end of file diff --git a/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocationDetailsViewModel.kt b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocationDetailsViewModel.kt new file mode 100644 index 0000000..f0e6c5f --- /dev/null +++ b/ui-features/location-details/src/main/java/com/github/yuriisurzhykov/purs/location/details/LocationDetailsViewModel.kt @@ -0,0 +1,43 @@ +package com.github.yuriisurzhykov.purs.location.details + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.yuriisurzhykov.purs.core.RequestResult +import com.github.yuriisurzhykov.purs.domain.model.Location +import com.github.yuriisurzhykov.purs.domain.usecase.BuildWorkingHoursListUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Provider + +@HiltViewModel +internal class LocationDetailsViewModel @Inject constructor( + fetchLocationDetailsUseCase: Provider +) : ViewModel() { + + val detailsResponse: StateFlow = + fetchLocationDetailsUseCase.get().workingHours() + .map { it.toState() } + .stateIn(viewModelScope, SharingStarted.Lazily, State.None) +} + +private fun RequestResult.toState(): State { + return when (this) { + is RequestResult.Error -> State.Error(data, error) + is RequestResult.InProgress -> State.Loading(data) + is RequestResult.Success -> State.Success(data) + } +} + +internal sealed class State(val location: Location?) { + data object None : State(location = null) + + class Loading(articles: Location? = null) : State(articles) + + class Error(location: Location? = null, val error: Throwable? = null) : State(location) + + class Success(location: Location) : State(location) +} \ No newline at end of file diff --git a/ui-features/location-details/src/main/res/drawable-nodpi/screen_background.webp b/ui-features/location-details/src/main/res/drawable-nodpi/screen_background.webp new file mode 100644 index 0000000..cdeec61 Binary files /dev/null and b/ui-features/location-details/src/main/res/drawable-nodpi/screen_background.webp differ diff --git a/ui-features/location-details/src/main/res/drawable/chevron_right.xml b/ui-features/location-details/src/main/res/drawable/chevron_right.xml new file mode 100644 index 0000000..62fff3f --- /dev/null +++ b/ui-features/location-details/src/main/res/drawable/chevron_right.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/ui-features/location-details/src/main/res/values/colors.xml b/ui-features/location-details/src/main/res/values/colors.xml new file mode 100644 index 0000000..6141f98 --- /dev/null +++ b/ui-features/location-details/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #4AA548 + #C2F7F3F3 + #FEE362 + #DE453A + \ No newline at end of file diff --git a/ui-features/location-details/src/main/res/values/strings.xml b/ui-features/location-details/src/main/res/values/strings.xml new file mode 100644 index 0000000..69d6977 --- /dev/null +++ b/ui-features/location-details/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Background + See full hours + %s - %s + Closed + Open 24 hours + Open until %s + Open until %s, reopens at %s + Opens again at %s + Opens %s at %s + Open until %s, reopens on %s %s + \ No newline at end of file