commit c6c8897cb4dad49d5708cdef7a39288eded94bdb Author: Nick Unuchek Date: Fri Feb 27 12:50:54 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9bceeed --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/.idea/ +/buildSrc/build/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f722cf --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Welcome +Welcome and thanks for accepting invitation. + +⚠️ This test project is designed with list of problems: +Compilation errors, runtime crashes and architecture problems and etc... +Do not pay attention to ui but pls proceed with the UX. + +# OVERVIEW: +It a simplest weather app: 1 screen and 2 dialogs. +First page allowed to see history list of places with time and weather. +Dialog that allowed to add other places. +Dialog that allowed to compare current weather with the saved one. + +### Test flow +Our suggestion for the flow. +Your goals: +- assemble the project, +- make a class to class code review and fix obvious places, +- while code review you can make notes like FIXME or TODO, +- After higlighte any architecture, naming each flow; + +Then we can discuss and implement: +- Next we should exclude room db usage, need to create a migration code that allowed to + temporarily use shared preferences and in memory caching, until we got another solution. + +But you can follow any suitable approach. + +Good luck and have fun. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..dfb9f3d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +android { + compileSdk = Config.SdkVersion.COMPILE + namespace = "com.testapp.test" + + defaultConfig { + applicationId = "com.testapp.test" + minSdk = Config.SdkVersion.MIN + targetSdk = Config.SdkVersion.COMPILE + versionCode = System.getenv("BUILD_NUMBER")?.toInt() ?: Config.Version.CODE + versionName = Config.Version.NAME + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + getByName(BuildType.DEBUG) { + isDebuggable = true + isMinifyEnabled = false + } + create(BuildType.STAGING) { + isDebuggable = true + isMinifyEnabled = true + isShrinkResources = true + } + getByName(BuildType.RELEASE) { + isDebuggable = false + isMinifyEnabled = true + isShrinkResources = true + } + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + viewBinding = true + } + + lint { + abortOnError = false + } + + // kapt block удаляем при переходе на KSP +} + +dependencies { + implementation(fileTree("dir" to "libs", "include" to listOf("*.jar", "*.aar"))) + // Modules + implementation(project(":style")) + implementation(project(":presentation")) + implementation(project(":data")) + implementation(project(":domain:domain")) + implementation(project(":domain:domain_impl")) + + + implementation(libs.appcompat) + implementation(libs.material) + implementation(libs.activity.ktx) + implementation(libs.core.ktx) + implementation(libs.fragment.ktx) + implementation(libs.splashscreen) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + debugImplementation(libs.leakcanary) + + coreLibraryDesugaring(libs.desugar.jdk.libs) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ff59496 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a7d1c93 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/testproject/application/WeatherApp.kt b/app/src/main/java/com/example/testproject/application/WeatherApp.kt new file mode 100644 index 0000000..3899145 --- /dev/null +++ b/app/src/main/java/com/example/testproject/application/WeatherApp.kt @@ -0,0 +1,8 @@ +package com.example.testproject.application + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class WeatherApp : Application() { +} \ No newline at end of file diff --git a/app/src/main/java/com/example/testproject/di/modules/DataSourceModule.kt b/app/src/main/java/com/example/testproject/di/modules/DataSourceModule.kt new file mode 100644 index 0000000..8e82247 --- /dev/null +++ b/app/src/main/java/com/example/testproject/di/modules/DataSourceModule.kt @@ -0,0 +1,32 @@ +package com.example.testproject.di.modules + +import com.testapp.data.api.RemoteWeatherDataSourceImpl +import com.testapp.data.api.RestClient +import com.testapp.data.api.RestClientImpl +import com.testapp.data.datasource.LocalWeatherStorage +import com.testapp.data.datasource.RemoteWeatherDataSource +import com.testapp.data.db.LocalWeatherStorageImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DataSourceModule { + + @Singleton + @Binds + fun restClient(impl: RestClientImpl): RestClient + + @Singleton + @Binds + fun localWeatherStorage(impl: LocalWeatherStorageImpl): LocalWeatherStorage + + @Singleton + @Binds + fun weatherApi(impl: RemoteWeatherDataSourceImpl): RemoteWeatherDataSource + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/testproject/di/modules/GateWayModule.kt b/app/src/main/java/com/example/testproject/di/modules/GateWayModule.kt new file mode 100644 index 0000000..d78e71a --- /dev/null +++ b/app/src/main/java/com/example/testproject/di/modules/GateWayModule.kt @@ -0,0 +1,20 @@ +package com.example.testproject.di.modules + +import com.testapp.data.gatway.WeatherGateWayImpl +import com.testapp.domain.domain.gatway.WeatherGateWay +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface GateWayModule { + + @Singleton + @Binds + fun bindGateWay(impl: WeatherGateWayImpl): WeatherGateWay + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/testproject/di/modules/UseCaseWeather.kt b/app/src/main/java/com/example/testproject/di/modules/UseCaseWeather.kt new file mode 100644 index 0000000..12a01e4 --- /dev/null +++ b/app/src/main/java/com/example/testproject/di/modules/UseCaseWeather.kt @@ -0,0 +1,37 @@ +package com.example.testproject.di.modules + +import com.testapp.domain.domain.usecase.GetWeather +import com.testapp.domain.domain.usecase.GetWeatherList +import com.testapp.domain.domain.usecase.RequestNewWeather +import com.testapp.domain.usecase.RequestNewWeatherImpl +import com.testapp.domain.domain.usecase.SaveWeather +import com.testapp.domain.usecase.GetWeatherImpl +import com.testapp.domain.usecase.GetWeatherListImpl +import com.testapp.domain.usecase.SaveWeatherImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface UseCaseWeather { + + @Binds + @Singleton + fun getWeather(impl: GetWeatherImpl): GetWeather + + @Binds + @Singleton + fun requestNewWeather(impl: RequestNewWeatherImpl): RequestNewWeather + + @Binds + @Singleton + fun getWeatherList(impl: GetWeatherListImpl): GetWeatherList + + @Binds + @Singleton + fun saveWeatherImpl(impl: SaveWeatherImpl): SaveWeather + +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..d4f0533 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("com.android.application") version libs.versions.agp.get() apply false + id("com.android.library") version libs.versions.agp.get() apply false + id("org.jetbrains.kotlin.android") version libs.versions.kotlin.get() apply false + id("com.google.dagger.hilt.android") version libs.versions.hilt.get() apply false + id("com.google.devtools.ksp") version libs.versions.ksp.get() apply false +} + diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..10b2ec2 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} diff --git a/buildSrc/src/main/java/BuildType.kt b/buildSrc/src/main/java/BuildType.kt new file mode 100644 index 0000000..27a1d67 --- /dev/null +++ b/buildSrc/src/main/java/BuildType.kt @@ -0,0 +1,5 @@ +object BuildType { + const val DEBUG = "debug" + const val STAGING = "staging" + const val RELEASE = "release" +} diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt new file mode 100644 index 0000000..334b5d9 --- /dev/null +++ b/buildSrc/src/main/java/Config.kt @@ -0,0 +1,19 @@ +object Config { + + object SdkVersion { + const val COMPILE = 36 + const val MIN = 23 + } + + object Version { + const val CODE = 1 + private const val NAME_MAJOR = 1 + private const val NAME_MINOR = 0 + private const val NAME_PATCH = 1 + const val NAME = "$NAME_MAJOR.$NAME_MINOR.$NAME_PATCH" + } + + object Api { + const val VERSION = "1.0" + } +} diff --git a/buildSrc/src/main/java/ProductFlavor.kt b/buildSrc/src/main/java/ProductFlavor.kt new file mode 100644 index 0000000..1e2ae7c --- /dev/null +++ b/buildSrc/src/main/java/ProductFlavor.kt @@ -0,0 +1,5 @@ +object ProductFlavor { + const val DEV = "dev" + const val STAGE = "stage" + const val PROD = "prod" +} diff --git a/buildSrc/src/main/java/extensions/ProjectExtensions.kt b/buildSrc/src/main/java/extensions/ProjectExtensions.kt new file mode 100644 index 0000000..1779a06 --- /dev/null +++ b/buildSrc/src/main/java/extensions/ProjectExtensions.kt @@ -0,0 +1,23 @@ +package extensions + +import org.gradle.api.Project +import java.io.FileInputStream +import java.util.* + +fun Project.getSigningConfigValue(key: String): String { + val signingPropsFile = file("../signing.properties") + val signingProps = Properties().apply { load(FileInputStream(signingPropsFile)) } + return signingProps[key] as String +} + +fun Project.getBuildConfigFieldValue(key: String): String { + val keyPropsFile = file("../key.properties") + val keyProps = Properties().apply { load(FileInputStream(keyPropsFile)) } + return "\"${keyProps[key]}\"" +} + +fun Project.getManifestPlaceholderValue(key: String): String { + val keyPropsFile = file("../key.properties") + val keyProps = Properties().apply { load(FileInputStream(keyPropsFile)) } + return keyProps[key].toString() +} diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/build.gradle.kts b/data/build.gradle.kts new file mode 100644 index 0000000..3d0bcf4 --- /dev/null +++ b/data/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +android { + compileSdk = Config.SdkVersion.COMPILE + namespace = "com.testappbank.test" + + defaultConfig { + minSdk = Config.SdkVersion.MIN + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("proguard-rules.pro") + + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + +} + +dependencies { + implementation(fileTree("dir" to "libs", "include" to listOf("*.jar"))) + implementation(project(":domain:domain")) + + implementation(libs.timber) + + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.retrofit.converter.scalars) + + + // Okhttp + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + + //ROOM + implementation(libs.room.runtime) + ksp(libs.room.compiler) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + coreLibraryDesugaring(libs.desugar.jdk.libs) +} \ No newline at end of file diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro new file mode 100644 index 0000000..d99b33c --- /dev/null +++ b/data/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/api/RemoteWeather.kt b/data/src/main/java/com/testapp/data/api/RemoteWeather.kt new file mode 100644 index 0000000..532ef4e --- /dev/null +++ b/data/src/main/java/com/testapp/data/api/RemoteWeather.kt @@ -0,0 +1,59 @@ +package com.testapp.data.api + +import com.google.gson.annotations.SerializedName + + +data class RemoteWeather( + @SerializedName("currentConditions") val cc: RemoteWeatherCurrentConditions +) + + +data class RemoteWeatherCurrentConditions( + @SerializedName("datetimeEpoch") val datetime: Long, + @SerializedName("temp") val temp: String, +) + +/** + * + * Response sample +"*"{ + "latitude":38.9697, + "longitude":-77.385, + "resolvedAddress":"Reston, VA, United States", + "address":" Reston,VA", + "timezone":"America/New_York", + "tzoffset":-5, + "description":"Cooling down with a chance of rain on Friday.", + "days":[ + { + "datetime":"2020-11-12", + "datetimeEpoch":1605157200, + "temp":59.6, + "feelslike":59.6, + "...""stations":{ + + }, + "source":"obs", + "hours":[ + { + "datetime":"01:00:00", + "..." + }, + "." + }, + "..." + ], + "alerts":[ + { + "event":"Flash Flood Watch", + "description":"..." + } + ], + "currentConditions":{ + "datetime":"2020-11-11T22:48:35", + "datetimeEpoch":160515291500, + "temp":67.9, + "..." + } + } + */ \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/api/RemoteWeatherDataSourceImpl.kt b/data/src/main/java/com/testapp/data/api/RemoteWeatherDataSourceImpl.kt new file mode 100644 index 0000000..589e402 --- /dev/null +++ b/data/src/main/java/com/testapp/data/api/RemoteWeatherDataSourceImpl.kt @@ -0,0 +1,33 @@ +package com.testapp.data.api + +import com.testapp.data.datasource.RemoteWeatherDataSource +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather +import timber.log.Timber +import java.util.Date +import java.util.UUID +import javax.inject.Inject + +class RemoteWeatherDataSourceImpl @Inject constructor( + private val restClient: RestClient +) : RemoteWeatherDataSource { + + + override suspend fun requestNewWeatherForPlace(placeName: String): ActionResult { + return try { + val remoteWeather = restClient.provideWeatherApi().getWeatherByLocation(placeName) + ActionResult.Success(remoteWeather.mapToDomainWhether(placeName)) + } catch (e: Throwable) { + Timber.e(e) + ActionResult.Error(e) + } + } +} + + +private fun RemoteWeather.mapToDomainWhether(placeName: String) = Weather( + id = UUID.randomUUID().toString(), + location = placeName, + date = Date(cc.datetime * 1000), + temperature = cc.temp.toDouble() +) diff --git a/data/src/main/java/com/testapp/data/api/RestClient.kt b/data/src/main/java/com/testapp/data/api/RestClient.kt new file mode 100644 index 0000000..7525356 --- /dev/null +++ b/data/src/main/java/com/testapp/data/api/RestClient.kt @@ -0,0 +1,5 @@ +package com.testapp.data.api + +interface RestClient { + fun provideWeatherApi(): WeatherApi +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/api/RestClientImpl.kt b/data/src/main/java/com/testapp/data/api/RestClientImpl.kt new file mode 100644 index 0000000..48420d8 --- /dev/null +++ b/data/src/main/java/com/testapp/data/api/RestClientImpl.kt @@ -0,0 +1,44 @@ +package com.testapp.data.api + +import com.google.gson.Gson +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class RestClientImpl @Inject constructor() : RestClient { + + companion object { + const val BASE_URL = + "https://weather.visualcrossing.com/" + const val TIME_OUT = 25L + } + + private val gson = Gson() + private val okHttpClient by lazy { provideOkHttp() } + private val retrofit: Retrofit by lazy { creteRetrofit(okHttpClient) } + + + private fun provideOkHttp(): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(TIME_OUT, TimeUnit.SECONDS) + .readTimeout(TIME_OUT, TimeUnit.SECONDS) + .writeTimeout(TIME_OUT, TimeUnit.SECONDS) + + builder.addInterceptor(HttpLoggingInterceptor(HttpLoggingInterceptor.Logger.DEFAULT)) + return builder.build() + } + + + private fun creteRetrofit(okHttpClient: OkHttpClient) = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + + override fun provideWeatherApi() = retrofit.create(WeatherApi::class.java) + +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/api/WeatherApi.kt b/data/src/main/java/com/testapp/data/api/WeatherApi.kt new file mode 100644 index 0000000..1035cd2 --- /dev/null +++ b/data/src/main/java/com/testapp/data/api/WeatherApi.kt @@ -0,0 +1,13 @@ +package com.testapp.data.api + +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface WeatherApi { + @GET("VisualCrossingWebServices/rest/services/timeline/{location}") + suspend fun getWeatherByLocation( + @Path("location") location: String, + @Query("key") key: String = "3CHX9BE656PW6PYSV43FA493M", + ): RemoteWeather +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/datasource/LocalWeatherStorage.kt b/data/src/main/java/com/testapp/data/datasource/LocalWeatherStorage.kt new file mode 100644 index 0000000..a58146a --- /dev/null +++ b/data/src/main/java/com/testapp/data/datasource/LocalWeatherStorage.kt @@ -0,0 +1,13 @@ +package com.testapp.data.datasource + +import com.testapp.domain.domain.models.Weather +import kotlinx.coroutines.flow.Flow + +interface LocalWeatherStorage { + + suspend fun getWeather(id: String): Weather? + + suspend fun saveWeather(weather: Weather) + + fun subscribeForWeatherList(): Flow> +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/datasource/RemoteWeatherDataSource.kt b/data/src/main/java/com/testapp/data/datasource/RemoteWeatherDataSource.kt new file mode 100644 index 0000000..2ae5fd1 --- /dev/null +++ b/data/src/main/java/com/testapp/data/datasource/RemoteWeatherDataSource.kt @@ -0,0 +1,9 @@ +package com.testapp.data.datasource + +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather + +interface RemoteWeatherDataSource { + suspend fun requestNewWeatherForPlace(placeName: String): ActionResult + +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/db/DbFactory.kt b/data/src/main/java/com/testapp/data/db/DbFactory.kt new file mode 100644 index 0000000..b35d928 --- /dev/null +++ b/data/src/main/java/com/testapp/data/db/DbFactory.kt @@ -0,0 +1,27 @@ +package com.testapp.data.db + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class DbFactory @Inject constructor( + @ApplicationContext val context: Context +) { + + companion object { + const val DATA_BASE_NAME = "WeatherDb" + } + + private val database by lazy { createDB(context) } + + private fun createDB(context: Context) = + Room.databaseBuilder(context, WeatherDb::class.java, DATA_BASE_NAME) + .addCallback(object : RoomDatabase.Callback() {}) + .build() + + private fun provideDB() = database + + fun provideWeatherDao() = provideDB().weatherDao() +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/db/LocalWeatherStorageImpl.kt b/data/src/main/java/com/testapp/data/db/LocalWeatherStorageImpl.kt new file mode 100644 index 0000000..05e7452 --- /dev/null +++ b/data/src/main/java/com/testapp/data/db/LocalWeatherStorageImpl.kt @@ -0,0 +1,49 @@ +package com.testapp.data.db + +import com.testapp.data.datasource.LocalWeatherStorage +import com.testapp.data.db.table.WeatherEntity +import com.testapp.domain.domain.models.Weather +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import java.util.Date +import javax.inject.Inject + + +class LocalWeatherStorageImpl @Inject constructor( + private val dbFactory: DbFactory, +) : LocalWeatherStorage { + + override suspend fun getWeather(id: String): Weather? { + return dbFactory.provideWeatherDao().getWeather(id)?.toWeather() + } + + override suspend fun saveWeather(weather: Weather) { + dbFactory.provideWeatherDao().insert(listOf(weather.toWeather())) + } + + override fun subscribeForWeatherList(): Flow> { + return dbFactory.provideWeatherDao() + .observeWeatherList().map { weatherEntities -> + weatherEntities.map { it.toWeather() } + }.onStart { + if (dbFactory.provideWeatherDao().getSavedPlaceCount() == 0) + emit(emptyList()) + } + } +} + +private fun WeatherEntity.toWeather() = Weather( + id = _id, + location = location, + date = Date(date), + temperature = temperature ?: 0.0, +) + + +private fun Weather.toWeather() = WeatherEntity( + _id = id, + location = location, + date = date.time, + temperature = temperature, +) \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/db/WeatherDb.kt b/data/src/main/java/com/testapp/data/db/WeatherDb.kt new file mode 100644 index 0000000..fb4b0bd --- /dev/null +++ b/data/src/main/java/com/testapp/data/db/WeatherDb.kt @@ -0,0 +1,19 @@ +package com.testapp.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.testapp.data.db.dao.WeatherDao +import com.testapp.data.db.table.WeatherEntity + + +@Database( + entities = [ + WeatherEntity::class, + ], + version = 1, + exportSchema = false +) +abstract class WeatherDb : RoomDatabase() { + abstract fun weatherDao(): WeatherDao + +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/db/dao/WeatherDao.kt b/data/src/main/java/com/testapp/data/db/dao/WeatherDao.kt new file mode 100644 index 0000000..57db5f3 --- /dev/null +++ b/data/src/main/java/com/testapp/data/db/dao/WeatherDao.kt @@ -0,0 +1,28 @@ +package com.testapp.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.testapp.data.db.table.WeatherEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface WeatherDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(commodityInfoList: List) + + @Query("Select count(*) from ${WeatherEntity.TABLE_NAME}") + suspend fun getSavedPlaceCount(): Int + + @Query("Select * from ${WeatherEntity.TABLE_NAME}") + fun observeWeatherList(): Flow> + + @Query("Select * from ${WeatherEntity.TABLE_NAME} where ${WeatherEntity.ID} =:id ") + suspend fun getWeather(id: String): WeatherEntity? + + @Query("Delete from ${WeatherEntity.TABLE_NAME} where ${WeatherEntity.ID} =:id") + suspend fun delete(id: String) + +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/db/table/WeatherEntity.kt b/data/src/main/java/com/testapp/data/db/table/WeatherEntity.kt new file mode 100644 index 0000000..7b87abd --- /dev/null +++ b/data/src/main/java/com/testapp/data/db/table/WeatherEntity.kt @@ -0,0 +1,27 @@ +package com.testapp.data.db.table + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = WeatherEntity.TABLE_NAME) +class WeatherEntity( + @ColumnInfo(name = ID) + @PrimaryKey + val _id: String, + @ColumnInfo(name = NAME) + val location: String, + @ColumnInfo(name = DATE) + val date: Long, + @ColumnInfo(name = TEMP) + val temperature: Double? +) { + companion object { + const val TABLE_NAME = "SavedWeather" + const val ID = "_id" + const val NAME = "location" + const val DATE = "date" + const val TEMP = "temp" + } + +} \ No newline at end of file diff --git a/data/src/main/java/com/testapp/data/gatway/WeatherGateWayImpl.kt b/data/src/main/java/com/testapp/data/gatway/WeatherGateWayImpl.kt new file mode 100644 index 0000000..dafb9b0 --- /dev/null +++ b/data/src/main/java/com/testapp/data/gatway/WeatherGateWayImpl.kt @@ -0,0 +1,33 @@ +package com.testapp.data.gatway + +import com.testapp.data.datasource.LocalWeatherStorage +import com.testapp.data.datasource.RemoteWeatherDataSource +import com.testapp.domain.domain.gatway.WeatherGateWay +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.domain.usecase.GetWeatherList +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class WeatherGateWayImpl @Inject constructor( + private val localWeatherStorage: LocalWeatherStorage, + private val remoteWeatherDataSource: RemoteWeatherDataSource, + private val getWeatherList: GetWeatherList, +) : WeatherGateWay { + + override suspend fun getWeather(id: String): Weather { + return localWeatherStorage.getWeather(id) ?: throw RuntimeException("No Weather by id") + } + + override suspend fun subscribeForWeatherList(): Flow> { + return getWeatherList.subscribeForWeatherList() + } + + override suspend fun requestNewWeatherForPlace(placeName: String): ActionResult { + return remoteWeatherDataSource.requestNewWeatherForPlace(placeName) + } + + override suspend fun saveWeather(weather: Weather) { + localWeatherStorage.saveWeather(weather) + } +} \ No newline at end of file diff --git a/domain/domain/.gitignore b/domain/domain/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/domain/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/domain/build.gradle.kts b/domain/domain/build.gradle.kts new file mode 100644 index 0000000..329cdd0 --- /dev/null +++ b/domain/domain/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +android { + compileSdk = Config.SdkVersion.COMPILE + namespace = "com.testappbank.test" + + defaultConfig { + minSdk = Config.SdkVersion.MIN + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("proguard-rules.pro") + + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + +} + +dependencies { + implementation(project(":domain:domain_impl")) + + implementation(fileTree("dir" to "libs", "include" to listOf("*.jar"))) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + coreLibraryDesugaring(libs.desugar.jdk.libs) +} \ No newline at end of file diff --git a/domain/domain/proguard-rules.pro b/domain/domain/proguard-rules.pro new file mode 100644 index 0000000..d99b33c --- /dev/null +++ b/domain/domain/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/domain/domain/src/main/java/com/testapp/domain/domain/gatway/WeatherGateWay.kt b/domain/domain/src/main/java/com/testapp/domain/domain/gatway/WeatherGateWay.kt new file mode 100644 index 0000000..64a35c4 --- /dev/null +++ b/domain/domain/src/main/java/com/testapp/domain/domain/gatway/WeatherGateWay.kt @@ -0,0 +1,14 @@ +package com.testapp.domain.domain.gatway + +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather +import kotlinx.coroutines.flow.Flow + +interface WeatherGateWay { + + suspend fun getWeather(id: String): Weather + suspend fun saveWeather(weather:Weather ) + suspend fun subscribeForWeatherList(): Flow> + suspend fun requestNewWeatherForPlace(placeName: String): ActionResult + +} \ No newline at end of file diff --git a/domain/domain/src/main/java/com/testapp/domain/domain/models/ActionResult.kt b/domain/domain/src/main/java/com/testapp/domain/domain/models/ActionResult.kt new file mode 100644 index 0000000..4b9d705 --- /dev/null +++ b/domain/domain/src/main/java/com/testapp/domain/domain/models/ActionResult.kt @@ -0,0 +1,8 @@ +package com.testapp.domain.domain.models + + +sealed class ActionResult { + + class Success(val data: Data) : ActionResult() + class Error(val reason: Throwable? = null) : ActionResult() +} \ No newline at end of file diff --git a/domain/domain/src/main/java/com/testapp/domain/domain/models/Weather.kt b/domain/domain/src/main/java/com/testapp/domain/domain/models/Weather.kt new file mode 100644 index 0000000..0bbffb5 --- /dev/null +++ b/domain/domain/src/main/java/com/testapp/domain/domain/models/Weather.kt @@ -0,0 +1,29 @@ +package com.testapp.domain.domain.models + +import java.util.Date + +data class Weather( + val id: String, + val location: String, + val date: Date, + val temperature: Double +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Weather + + if (id != other.id) return false + if (location != other.location) return false + if (date != other.date) return false + if (temperature != other.temperature) return false + + return true + } + + override fun hashCode(): Int { + return id.hashCode() + } +} + diff --git a/domain/domain/src/main/java/com/testapp/domain/domain/usecase/GetWeather.kt b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/GetWeather.kt new file mode 100644 index 0000000..45d48b5 --- /dev/null +++ b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/GetWeather.kt @@ -0,0 +1,9 @@ +package com.testapp.domain.domain.usecase + +import com.testapp.domain.domain.models.Weather + +interface GetWeather { + + suspend fun getWeather(id:String):Weather + +} \ No newline at end of file diff --git a/domain/domain/src/main/java/com/testapp/domain/domain/usecase/GetWeatherList.kt b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/GetWeatherList.kt new file mode 100644 index 0000000..84250dd --- /dev/null +++ b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/GetWeatherList.kt @@ -0,0 +1,11 @@ +package com.testapp.domain.domain.usecase + +import com.testapp.domain.domain.models.Weather +import kotlinx.coroutines.flow.Flow + +interface GetWeatherList { + + suspend fun subscribeForWeatherList(): Flow> + + +} \ No newline at end of file diff --git a/domain/domain/src/main/java/com/testapp/domain/domain/usecase/RequestNewWeather.kt b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/RequestNewWeather.kt new file mode 100644 index 0000000..a2cf066 --- /dev/null +++ b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/RequestNewWeather.kt @@ -0,0 +1,10 @@ +package com.testapp.domain.domain.usecase + +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather + +interface RequestNewWeather { + + suspend fun requestNewWeatherForPlace(placeName: String): ActionResult + +} \ No newline at end of file diff --git a/domain/domain/src/main/java/com/testapp/domain/domain/usecase/SaveWeather.kt b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/SaveWeather.kt new file mode 100644 index 0000000..4d8cbf3 --- /dev/null +++ b/domain/domain/src/main/java/com/testapp/domain/domain/usecase/SaveWeather.kt @@ -0,0 +1,10 @@ +package com.testapp.domain.domain.usecase + +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather + +interface SaveWeather { + + suspend fun saveWeather(weather: Weather) + +} \ No newline at end of file diff --git a/domain/domain_impl/.gitignore b/domain/domain_impl/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/domain/domain_impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/domain/domain_impl/build.gradle.kts b/domain/domain_impl/build.gradle.kts new file mode 100644 index 0000000..0168faf --- /dev/null +++ b/domain/domain_impl/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +android { + compileSdk = Config.SdkVersion.COMPILE + namespace = "com.testappbank.test" + + defaultConfig { + minSdk = Config.SdkVersion.MIN + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("proguard-rules.pro") + + } + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + +} + +dependencies { + implementation(fileTree("dir" to "libs", "include" to listOf("*.jar"))) + + implementation(project(":domain:domain")) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + coreLibraryDesugaring(libs.desugar.jdk.libs) +} \ No newline at end of file diff --git a/domain/domain_impl/src/main/java/com/testapp/domain/usecase/GetWeatherImpl.kt b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/GetWeatherImpl.kt new file mode 100644 index 0000000..9fb0d64 --- /dev/null +++ b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/GetWeatherImpl.kt @@ -0,0 +1,16 @@ +package com.testapp.domain.usecase + +import com.testapp.domain.domain.gatway.WeatherGateWay +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.domain.usecase.GetWeather +import javax.inject.Inject + +class GetWeatherImpl @Inject constructor( + private val gateWay: WeatherGateWay, +) : GetWeather { + + override suspend fun getWeather(id: String): Weather = + gateWay.getWeather(id) + + +} \ No newline at end of file diff --git a/domain/domain_impl/src/main/java/com/testapp/domain/usecase/GetWeatherListImpl.kt b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/GetWeatherListImpl.kt new file mode 100644 index 0000000..98e7a4a --- /dev/null +++ b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/GetWeatherListImpl.kt @@ -0,0 +1,16 @@ +package com.testapp.domain.usecase + +import com.testapp.domain.domain.gatway.WeatherGateWay +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.domain.usecase.GetWeatherList +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetWeatherListImpl @Inject constructor( + private val gateWay: WeatherGateWay, +) : GetWeatherList { + + override suspend fun subscribeForWeatherList(): Flow> = + gateWay.subscribeForWeatherList() + +} \ No newline at end of file diff --git a/domain/domain_impl/src/main/java/com/testapp/domain/usecase/RequestNewWeatherImpl.kt b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/RequestNewWeatherImpl.kt new file mode 100644 index 0000000..2a8a826 --- /dev/null +++ b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/RequestNewWeatherImpl.kt @@ -0,0 +1,17 @@ +package com.testapp.domain.usecase + +import com.testapp.domain.domain.gatway.WeatherGateWay +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.domain.usecase.RequestNewWeather +import javax.inject.Inject + +class RequestNewWeatherImpl @Inject constructor( + private val gateWay: WeatherGateWay, + + ) : RequestNewWeather { + + override suspend fun requestNewWeatherForPlace(placeName: String): ActionResult = + gateWay.requestNewWeatherForPlace(placeName) + +} \ No newline at end of file diff --git a/domain/domain_impl/src/main/java/com/testapp/domain/usecase/SaveWeatherImpl.kt b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/SaveWeatherImpl.kt new file mode 100644 index 0000000..a295f57 --- /dev/null +++ b/domain/domain_impl/src/main/java/com/testapp/domain/usecase/SaveWeatherImpl.kt @@ -0,0 +1,17 @@ +package com.testapp.domain.usecase + +import com.testapp.domain.domain.gatway.WeatherGateWay +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.domain.usecase.SaveWeather +import javax.inject.Inject + +class SaveWeatherImpl @Inject constructor( + val gateWay: WeatherGateWay +) : SaveWeather { + + override suspend fun saveWeather(weather: Weather) { + gateWay.saveWeather(weather) + } + +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..a626f61 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,33 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..9a8461b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,112 @@ +[versions] +agp = "9.0.1" +kotlin = "2.3.10" +hilt = "2.59.2" +ksp = "2.3.6" +material = "1.13.0" +androidxActivityKtx = "1.12.4" +androidxFragmentKtx = "1.8.9" +coreKtx = "1.17.0" +splashscreen = "1.2.0" +appcompat = "1.7.1" +constraintlayout = "2.2.1" +recyclerview = "1.4.0" +swiperefreshlayout = "1.2.0" +cardview = "1.0.0" +viewpager2 = "1.1.0" +asyncLayoutInflater = "1.1.0" +photoView = "2.3.0" +facebookShimmer = "0.5.0" +gson = "2.13.2" +leakcanary = "2.14" +timber = "5.0.1" +junit4 = "4.13.2" +androidxCoreTesting = "2.2.0" +intercom = "17.4.5" +pinwheel = "3.6.0" +room = "2.8.4" +coroutines = "1.10.2" +lifecycle = "2.10.0" +retrofit = "3.0.0" +okhttp = "5.3.2" +appspector = "1.4.4" +glide = "5.0.5" +mockitoInline = "5.2.0" +mockitoKotlin = "6.2.3" +desugarJdkLibs = "2.1.5" + +[libraries] +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } + +material = { module = "com.google.android.material:material", version.ref = "material" } +activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidxActivityKtx" } +fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidxFragmentKtx" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } +recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } +swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } +cardview = { module = "androidx.cardview:cardview", version.ref = "cardview" } +viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } +async-layoutinflater = { module = "androidx.asynclayoutinflater:asynclayoutinflater", version.ref = "asyncLayoutInflater" } +photo-view = { module = "com.github.chrisbanes:PhotoView", version.ref = "photoView" } +facebook-shimmer = { module = "com.facebook.shimmer:shimmer", version.ref = "facebookShimmer" } + +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } + +junit4 = { module = "junit:junit", version.ref = "junit4" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidxCoreTesting" } + +intercom = { module = "io.intercom.android:intercom-sdk", version.ref = "intercom" } +pinwheel = { module = "com.getpinwheel:pinwheel-android", version.ref = "pinwheel" } + +room-runtime = { module = "androidx.room:room-ktx", version.ref = "room" } +room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt" } +hilt-navigation-fragment = { module = "androidx.hilt:hilt-navigation-fragment", version.ref = "hilt" } + +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } + +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines" } +coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + +lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycle" } +lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } +lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } +lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } + +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } +retrofit-converter-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "retrofit" } + +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } + +appspector-sdk = { module = "com.appspector:android-sdk", version.ref = "appspector" } +appspector-sdk-noop = { module = "com.appspector:android-sdk-noop", version.ref = "appspector" } + +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } + +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } + +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000..f6b961f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100755 index 0000000..4ccefcb --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Oct 16 00:26:05 CEST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/presentation/.gitignore b/presentation/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/presentation/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts new file mode 100644 index 0000000..ad83647 --- /dev/null +++ b/presentation/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") +} + +android { + compileSdk = Config.SdkVersion.COMPILE + namespace = "com.testappbank.test" + + defaultConfig { + minSdk = Config.SdkVersion.MIN + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("proguard-rules.pro") + + } + + + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + viewBinding = true + } + +} + +dependencies { + implementation(fileTree("dir" to "libs", "include" to listOf("*.jar"))) + // Modules + implementation(project(":style")) + implementation(project(":domain:domain")) + + implementation(libs.lifecycle.livedata.ktx) + implementation(libs.lifecycle.process) + implementation(libs.lifecycle.viewmodel.ktx) + implementation(libs.lifecycle.runtime.ktx) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + coreLibraryDesugaring(libs.desugar.jdk.libs) +} \ No newline at end of file diff --git a/presentation/proguard-rules.pro b/presentation/proguard-rules.pro new file mode 100644 index 0000000..d99b33c --- /dev/null +++ b/presentation/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml new file mode 100644 index 0000000..393007f --- /dev/null +++ b/presentation/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/models/AdapterItem.kt b/presentation/src/main/java/com/testapp/domain/presentation/models/AdapterItem.kt new file mode 100644 index 0000000..753e4ed --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/models/AdapterItem.kt @@ -0,0 +1,41 @@ +package com.testapp.domain.presentation.models + +import android.view.View +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import androidx.viewbinding.ViewBindings + +sealed class AdapterItem { + data class DataItem(val data: DATA) : AdapterItem() + object EmptyScreen : AdapterItem() + object Skeleton : AdapterItem() + +} + +class AdapterItemDiff : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: AdapterItem, newItem: AdapterItem): Boolean { + return when { + oldItem.javaClass != newItem.javaClass -> false + oldItem is AdapterItem.Skeleton && newItem is AdapterItem.Skeleton -> true + oldItem is AdapterItem.EmptyScreen && newItem is AdapterItem.EmptyScreen -> true + oldItem is AdapterItem.DataItem<*> && newItem is AdapterItem.DataItem<*> -> { + oldItem.data?.hashCode() == newItem.data.hashCode() + } + + else -> false + } + + + } + + override fun areContentsTheSame(oldItem: AdapterItem, newItem: AdapterItem): Boolean { + return oldItem.equals(newItem) + } +} + +abstract class AbsVH(val binding: VIEW) : + RecyclerView.ViewHolder(binding.root) { + + abstract fun bind(item: DATA) +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/MainActivity.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/MainActivity.kt new file mode 100644 index 0000000..93ff636 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/MainActivity.kt @@ -0,0 +1,30 @@ +package com.testapp.domain.presentation.screen + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import com.testapp.domain.presentation.screen.history.HistoryFragment +import com.testappbank.test.R +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + @Inject + lateinit var mainVM: MainVm + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + if (savedInstanceState == null) initDefaultScreen() + } + + private fun initDefaultScreen() { + supportFragmentManager.beginTransaction().apply { + add(R.id.content, HistoryFragment.newInstance()) + commitAllowingStateLoss() + } + } + + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/MainVm.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/MainVm.kt new file mode 100644 index 0000000..5a351c7 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/MainVm.kt @@ -0,0 +1,9 @@ +package com.testapp.domain.presentation.screen + +import androidx.lifecycle.ViewModel +import javax.inject.Inject + +class MainVm @Inject constructor() : ViewModel() { + + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/compare/CompareWeatherDialogFragment.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/compare/CompareWeatherDialogFragment.kt new file mode 100644 index 0000000..3d21151 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/compare/CompareWeatherDialogFragment.kt @@ -0,0 +1,94 @@ +package com.testapp.domain.presentation.screen.compare + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import com.testapp.ViewBindingDialogFragment +import com.testapp.domain.domain.models.Weather +import com.testappbank.test.databinding.DialogFramentCompareHistoryBinding +import dagger.hilt.android.AndroidEntryPoint +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject + +@AndroidEntryPoint +class CompareWeatherDialogFragment : + ViewBindingDialogFragment() { + + @Inject + lateinit var factory: DetailWeatherVM.Factory + private val dateFormater = SimpleDateFormat("yyyy-MM-dd hh:mm", Locale.getDefault()) + + private val detailWeatherVM: DetailWeatherVM by viewModels { + DetailWeatherVM.providesFactory( + assistedFactory = factory, + userId = arguments?.getString(WEATHER_ID) ?: throw RuntimeException("no id") + ) + } + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> DialogFramentCompareHistoryBinding + get() = DialogFramentCompareHistoryBinding::inflate + + override fun viewSetup() { + binding.apply { + detailWeatherVM.savedWeatherReady.observe(viewLifecycleOwner) { isReady -> + oldWeather.root.visibility = if (isReady) View.VISIBLE else View.GONE + oldWeatherSkeleton.root.visibility = if (isReady) View.GONE else View.VISIBLE + } + + detailWeatherVM.newWeatherReady.observe(viewLifecycleOwner) { isReady -> + newWeather.root.visibility = if (isReady) View.VISIBLE else View.GONE + newWeatherSkeleton.root.visibility = if (isReady) View.GONE else View.VISIBLE + } + + detailWeatherVM.savedWeather.observe(viewLifecycleOwner) { weather -> + oldWeather.tempratureAndLocation.text = requireContext().getString( + com.testapp.test.R.string.weather_and_place, + weather?.temperature.toString() ?: "", + weather?.location ?: "", + ) + weather?.date?.let { oldWeather.txtData.text = dateFormater.format(it) } + } + + detailWeatherVM.newWeather.observe(viewLifecycleOwner) { weather -> + newWeather.tempratureAndLocation.text = requireContext().getString( + com.testapp.test.R.string.weather_and_place, + weather?.temperature.toString() ?: "", + weather?.location ?: "", + ) + weather?.date?.let { newWeather.txtData.text = dateFormater.format(it) } + } + + detailWeatherVM.result.observe(viewLifecycleOwner) { distance -> + + tempratureConclusion.text = when { + distance < 0 -> requireContext().getString( + com.testapp.test.R.string.weather_colder, + distance.toString() + ) + + distance > 0 -> requireContext().getString( + com.testapp.test.R.string.weather_warmer, + distance.toString() + ) + + else -> requireContext().getString(com.testapp.test.R.string.weather_same) + } + + } + } + } + + + companion object { + const val WEATHER_ID = "weather" + fun newInstance(weather: Weather) = + CompareWeatherDialogFragment().apply { + arguments = bundleOf( + weather.id to WEATHER_ID + ) + } + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/compare/DetailWeatherVM.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/compare/DetailWeatherVM.kt new file mode 100644 index 0000000..8956150 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/compare/DetailWeatherVM.kt @@ -0,0 +1,72 @@ +package com.testapp.domain.presentation.screen.compare + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.domain.usecase.GetWeather +import com.testapp.domain.domain.usecase.RequestNewWeather +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + + +class DetailWeatherVM @AssistedInject constructor( + val getWeather: GetWeather, + val requestNewWeather: RequestNewWeather, + @Assisted val savedWeatherId: String +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(savedWeatherId: String): DetailWeatherVM + } + + + @Suppress("UNCHECKED_CAST") + companion object { + fun providesFactory( + assistedFactory: Factory, + userId: String + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + + return assistedFactory.create(userId) as T + } + } + } + + val savedWeather = MutableLiveData(null) + val savedWeatherReady = savedWeather.map { it != null } + + val newWeather = MutableLiveData(null) + val newWeatherReady = savedWeather.map { it != null } + + val result = MutableLiveData() + + init { + requestWeather() + } + + private fun requestWeather() { + viewModelScope.launch { + val weather = getWeather.getWeather(savedWeatherId) + savedWeather.postValue(weather) + val updatedWeather = requestNewWeather.requestNewWeatherForPlace(weather.location) + if (updatedWeather is ActionResult.Success) { + newWeather.postValue(updatedWeather.data as Weather?) + val distance = + ((weather.temperature - updatedWeather.data.temperature) * 100).roundToInt() / 100.0 + result.postValue(distance) + } + } + + } + + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryAdapter.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryAdapter.kt new file mode 100644 index 0000000..36a3433 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryAdapter.kt @@ -0,0 +1,98 @@ +package com.testapp.domain.presentation.screen.history + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.RecyclerView +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.presentation.models.AbsVH +import com.testapp.domain.presentation.models.AdapterItem +import com.testapp.domain.presentation.models.AdapterItemDiff +import com.testappbank.test.databinding.ItemEmptyListBinding +import com.testappbank.test.databinding.ItemSkeletonBinding +import com.testappbank.test.databinding.ItemWeatherBinding +import java.text.SimpleDateFormat +import java.util.Locale + +class HistoryAdapter( + private val onWeatherSelected: (Weather) -> Unit +) : RecyclerView.Adapter>() { + + private val listDiffer = AsyncListDiffer(this, AdapterItemDiff()) + private val dateFormat = SimpleDateFormat("yyyy-MM-dd hh:mm", Locale.getDefault()) + + override fun getItemViewType(position: Int): Int = + when (listDiffer.currentList[position]) { + is AdapterItem.Skeleton -> SKELETON + is AdapterItem.EmptyScreen -> EMPTY + is AdapterItem.DataItem<*> -> ITEM + else -> throw RuntimeException("Unknown item type!") + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbsVH<*, *> { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + SKELETON -> SkeletonVH(ItemSkeletonBinding.inflate(inflater, parent, false)) + EMPTY -> EmptyVH(ItemEmptyListBinding.inflate(inflater, parent, false)) + ITEM -> WeatherView(ItemWeatherBinding.inflate(inflater, parent, false)) + else -> throw RuntimeException("Unknown item type!") + } + } + + override fun onBindViewHolder(holder: AbsVH<*, *>, position: Int) { + val item = listDiffer.currentList[position] + when (holder) { + is SkeletonVH -> holder.bind(Unit) + is EmptyVH -> holder.bind(Unit) + is WeatherView -> ((item as? AdapterItem.DataItem<*>)?.data as? Weather)?.let { + holder.bind(it) + } + } + } + + override fun getItemCount(): Int = listDiffer.currentList.size + + fun updateItems(list: List) { + if (list.isEmpty()) { + listDiffer.submitList(listOf(AdapterItem.EmptyScreen)) + } else listDiffer.submitList(list) + } + + + inner class SkeletonVH(binding: ItemSkeletonBinding) : + AbsVH(binding) { + override fun bind(item: Unit) {} + } + + inner class EmptyVH(binding: ItemEmptyListBinding) : + AbsVH(binding) { + override fun bind(item: Unit) {} + } + + + inner class WeatherView(binding: ItemWeatherBinding) : + AbsVH(binding) { + override fun bind(item: Weather) { + binding.apply { + val context = binding.root.context + tempratureAndLocation.text = context.getString( + com.testapp.test.R.string.weather_and_place, + item.temperature.toString(), + item.location + ) + txtData.text = dateFormat.format(item.date) + root.setOnClickListener { + onWeatherSelected.invoke(item) + } + } + } + + } + + companion object { + private const val SKELETON = 1 + private const val ITEM = 2 + private const val EMPTY = 3 + } + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryFragment.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryFragment.kt new file mode 100644 index 0000000..9d7de12 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryFragment.kt @@ -0,0 +1,65 @@ +package com.testapp.domain.presentation.screen.history + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.testapp.ViewBindingFragment +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.presentation.screen.compare.CompareWeatherDialogFragment +import com.testapp.domain.presentation.screen.request.RequestNewLocationDialog +import com.testappbank.test.databinding.FramentHistoryBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HistoryFragment : ViewBindingFragment() { + + private val historyVM: HistoryVM by viewModels() + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> FramentHistoryBinding = + FramentHistoryBinding::inflate + + private val adapter by lazy { HistoryAdapter(this::onWeatherSelected) } + + private val itemDecoration by lazy { + DividerItemDecoration(requireContext(), LinearLayout.HORIZONTAL) + } + + override fun viewSetup() { + + binding.apply { + setupList(weatherList) + requestNewWeather.setOnClickListener { historyVM.navigateToNewWeather() } + } + historyVM.displayRequestNew.observe(viewLifecycleOwner) { + RequestNewLocationDialog.newInstance().show(childFragmentManager, null) + } + } + + private fun setupList(weatherList: RecyclerView) { + val layoutManager = LinearLayoutManager(context) + weatherList.layoutManager = layoutManager + weatherList.adapter = adapter + weatherList.addItemDecoration(itemDecoration) + + historyVM.weatherList.observe(viewLifecycleOwner) { list -> + adapter.updateItems(list) + } + } + + private fun onWeatherSelected(weather: Weather) { + CompareWeatherDialogFragment.newInstance(weather).show(childFragmentManager, null) + } + + companion object { + + fun newInstance() = HistoryFragment().apply { + arguments = Bundle() + } + + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryVM.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryVM.kt new file mode 100644 index 0000000..8d01325 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/history/HistoryVM.kt @@ -0,0 +1,59 @@ +package com.testapp.domain.presentation.screen.history + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.testapp.domain.domain.models.Weather +import com.testapp.domain.domain.usecase.GetWeatherList +import com.testapp.domain.presentation.models.AdapterItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HistoryVM @Inject constructor( + private val getWeatherList: GetWeatherList, +) : ViewModel() { + fun navigateToNewWeather() { + displayRequestNew.postValue(Unit) + } + + val weatherList = MutableLiveData>() + + val diaplyError = MutableLiveData() + + val displayRequestNew = MutableLiveData() + + init { + GlobalScope.launch { + getWeatherList.subscribeForWeatherList() + .onStart { + weatherList.postValue( + listOf( + AdapterItem.Skeleton, AdapterItem.Skeleton, AdapterItem.Skeleton, + AdapterItem.Skeleton, AdapterItem.Skeleton, AdapterItem.Skeleton, + AdapterItem.Skeleton, AdapterItem.Skeleton, AdapterItem.Skeleton, + ) + ) + } + .onEach { list -> + weatherList.postValue(list.map { it.toAdapterItem() }) + diaplyError.postValue(null) + } + .catch { cause -> diaplyError.postValue(cause) } + .launchIn(viewModelScope) + } + } + + +} + +private fun Weather.toAdapterItem(): AdapterItem.DataItem { + return AdapterItem.DataItem(this) +} + diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/request/RequestNewLocationDialog.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/request/RequestNewLocationDialog.kt new file mode 100644 index 0000000..718c72d --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/request/RequestNewLocationDialog.kt @@ -0,0 +1,48 @@ +package com.testapp.domain.presentation.screen.request + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.fragment.app.viewModels +import com.testapp.ViewBindingDialogFragment +import com.testappbank.test.databinding.DialogFramentRequestBinding +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class RequestNewLocationDialog : + ViewBindingDialogFragment() { + + val requestNewWeatherVM: RequestNewWeatherVM by viewModels() + + override val bindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> DialogFramentRequestBinding + get() = DialogFramentRequestBinding::inflate + + override fun viewSetup() { + binding.locationField.setOnEditorActionListener { v, actionId, event -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + requestForNewLocation() + true + }else false; + } + binding.search.setOnClickListener { requestForNewLocation() } + requestNewWeatherVM.closeDialog.observe(viewLifecycleOwner) { + if (it) dismiss() + } + requestNewWeatherVM.error.observe(viewLifecycleOwner) { + binding.locationLayout.error = it + } + } + + private fun requestForNewLocation() { + binding.locationField.text?.toString()?.let { name -> + requestNewWeatherVM.requestWeather(name) + } + } + + companion object { + fun newInstance(): RequestNewLocationDialog { + return RequestNewLocationDialog() + } + } + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/screen/request/RequestNewWeatherVM.kt b/presentation/src/main/java/com/testapp/domain/presentation/screen/request/RequestNewWeatherVM.kt new file mode 100644 index 0000000..13cac9d --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/screen/request/RequestNewWeatherVM.kt @@ -0,0 +1,38 @@ +package com.testapp.domain.presentation.screen.request + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.testapp.domain.domain.models.ActionResult +import com.testapp.domain.domain.usecase.RequestNewWeather +import com.testapp.domain.domain.usecase.SaveWeather +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RequestNewWeatherVM @Inject constructor( + private val requestNewWeather: RequestNewWeather, + private val saveWeather: SaveWeather, +) : ViewModel() { + + val closeDialog = MutableLiveData(false) + val error = MutableLiveData() + + fun requestWeather(locationName: String) { + viewModelScope.launch { + error.postValue(null) + when (val result = requestNewWeather.requestNewWeatherForPlace(locationName)) { + is ActionResult.Error -> { + error.postValue(result.reason?.message) + } + + is ActionResult.Success -> { + saveWeather.saveWeather(result.data) + closeDialog.postValue(true) + } + } + } + } + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/testapp/domain/presentation/tools/SingleLiveEvent.kt b/presentation/src/main/java/com/testapp/domain/presentation/tools/SingleLiveEvent.kt new file mode 100644 index 0000000..432e088 --- /dev/null +++ b/presentation/src/main/java/com/testapp/domain/presentation/tools/SingleLiveEvent.kt @@ -0,0 +1,58 @@ +package com.testapp.domain.presentation.tools + +import android.util.Log +import androidx.annotation.MainThread +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import java.util.concurrent.atomic.AtomicBoolean + + +/** + * A lifecycle-aware observable that sends only new updates after subscription, used for events like + * navigation and Snackbar messages. + * + * https://proandroiddev.com/singleliveevent-to-help-you-work-with-livedata-and-events-5ac519989c70 + * + * This avoids a common problem with events: on configuration change (like rotation) an update + * can be emitted if the observer is active. This LiveData only calls the observable if there's an + * explicit call to setValue() or call(). + * + * + * Note that only one observer is going to be notified of changes!! + */ +open class SingleLiveEvent : MutableLiveData() { + private val pending = + AtomicBoolean(false) + + @MainThread + override fun observe(owner: LifecycleOwner, observer: Observer) { + if (hasActiveObservers()) { + Log.w(TAG, "Multiple observers registered but only one will be notified of changes.") + } + // Observe the internal MutableLiveData + super.observe(owner) { t -> + if (pending.compareAndSet(true, false)) { + observer.onChanged(t) + } + } + } + + @MainThread + override fun setValue(t: T?) { + pending.set(true) + super.setValue(t) + } + + /** + * Used for cases where T is Void, to make calls cleaner. + */ + @MainThread + fun call() { + value = null + } + + companion object { + private const val TAG = "SingleLiveEvent" + } +} \ No newline at end of file diff --git a/presentation/src/main/res/layout/activity_main.xml b/presentation/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..901a5a9 --- /dev/null +++ b/presentation/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/dialog_frament_compare_history.xml b/presentation/src/main/res/layout/dialog_frament_compare_history.xml new file mode 100644 index 0000000..99b5133 --- /dev/null +++ b/presentation/src/main/res/layout/dialog_frament_compare_history.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/dialog_frament_request.xml b/presentation/src/main/res/layout/dialog_frament_request.xml new file mode 100644 index 0000000..5904fd2 --- /dev/null +++ b/presentation/src/main/res/layout/dialog_frament_request.xml @@ -0,0 +1,42 @@ + + + + + + + + + +