Merge pull request 'sprint-2' (#1) from sprint-2 into master

Reviewed-on: #1
This commit is contained in:
user5 2025-05-27 09:54:16 +00:00
commit 8a52a8537b
49 changed files with 1355 additions and 30 deletions

1
api-core/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

52
api-core/build.gradle.kts Normal file
View File

@ -0,0 +1,52 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.example.api.core"
compileSdk = 35
defaultConfig {
minSdk = 33
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit2.kotlinx.serialization.converter)
implementation(libs.retrofit2)
implementation(libs.okhttp)
implementation(libs.logging.interceptor)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
testImplementation(libs.junit)
testImplementation(libs.mockwebserver)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

View File

21
api-core/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -0,0 +1,24 @@
package com.example.api.core
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ApiRepositoryTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.api.core.test", appContext.packageName)
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,35 @@
package com.example.api.core.data.core
import com.example.api.core.domain.ApiRepository
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.create
/**
* фабрика для создание инстанса интерфейса без внешнего доступа к реализации
* */
object ApiFactory {
fun provideRepository(dataStoreRepository: DataStoreRepository): ApiRepository {
val json = Json { ignoreUnknownKeys = true }
val client = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.addInterceptor(AuthInterceptor(dataStoreRepository))
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.matule.ru/api/")
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.client(client)
.build()
return BaseApiRepository(retrofit.create<ApiService>(), dataStoreRepository, Handle.Base())
}
}

View File

@ -0,0 +1,47 @@
package com.example.api.core.data.core
import com.example.api.core.data.dto.AuthUserResponseDto
import com.example.api.core.data.dto.CatalogDto
import com.example.api.core.data.dto.CatalogItemDto
import com.example.api.core.data.dto.ProjectsDto
import com.example.api.core.data.dto.RegisterUserDto
import com.example.api.core.data.dto.SalesAndNewsDto
import com.example.api.core.data.dto.SignInUserDto
import com.example.api.core.data.dto.UserResponseDto
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
/**
* общий сервис для запросов
* */
internal interface ApiService {
@POST("collections/users/records")
suspend fun register(@Body registerUserDto: RegisterUserDto): UserResponseDto
@POST("collections/users/auth-with-password")
suspend fun auth(@Body signInUserDto: SignInUserDto): AuthUserResponseDto
@GET("collections/project/records")
suspend fun projects(): ProjectsDto
@GET("collections/news/records")
suspend fun salesAndNews(): SalesAndNewsDto
@GET("collections/products/records")
suspend fun catalog(): CatalogDto
@GET("collections/products/records")
suspend fun catalog(@Query("filter") filter: String): CatalogDto
@GET("collections/products/records/{id_product}")
suspend fun productDescription(@Path("id_product") productId: String): CatalogItemDto
}

View File

@ -0,0 +1,25 @@
package com.example.api.core.data.core
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
/**
* Перехватчик для установки токена в заголовок запроса
* */
class AuthInterceptor(private val dataStoreRepository: DataStoreRepository) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = runBlocking { dataStoreRepository.token().first() }
val request = chain.request().newBuilder().apply {
token?.let { addHeader("Authorization", "Bearer $token") }
}
return chain.proceed(request.build())
}
}

View File

@ -0,0 +1,57 @@
package com.example.api.core.data.core
import com.example.api.core.domain.ApiRepository
import com.example.api.core.domain.AuthUserResponse
import com.example.api.core.domain.FetchResult
import com.example.api.core.domain.RegisterUser
import com.example.api.core.domain.Projects
import retrofit2.HttpException
import java.net.UnknownHostException
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
/**
* общая структура обработки запросов
* */
internal interface Handle {
suspend fun <T> handle(action: suspend () -> T): FetchResult<T>
class Base : Handle {
override suspend fun <T> handle(action: suspend () -> T): FetchResult<T> {
return try {
val result = action.invoke()
FetchResult.Success(result)
} catch (e: UnknownHostException) {
FetchResult.Error(null, "no internet connection")
} catch (e: HttpException) {
FetchResult.Error(null, e.message())
} catch (e: Exception) {
FetchResult.Error(null, e.message!!)
}
}
}
}
internal class BaseApiRepository(
private val service: ApiService,
private val dataStoreRepository: DataStoreRepository,
private val handle: Handle,
) : RemoteMapper(), ApiRepository {
override suspend fun auth(registerUser: RegisterUser): FetchResult<AuthUserResponse> = handle.handle {
service.register(registerUser.toRegisterUserDto())
val response = service.auth(registerUser.toSignInUserDto())
dataStoreRepository.saveToken(response.token)
response.toUserResponse()
}
override suspend fun projects(): FetchResult<Projects> = handle.handle {
service.projects().toDomain()
}
}

View File

@ -0,0 +1,41 @@
package com.example.api.core.data.core
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
/**
* Локальное хранение токена
* */
interface DataStoreRepository {
suspend fun saveToken(token: String)
fun token(): Flow<String?>
class Base(private val preferences: DataStore<Preferences>) : DataStoreRepository {
override suspend fun saveToken(token: String) {
preferences.edit { settings ->
settings[TOKEN_KEY] = token
}
}
override fun token(): Flow<String?> {
return preferences.data.map { preferences ->
preferences[TOKEN_KEY]
}
}
}
private companion object {
val TOKEN_KEY = stringPreferencesKey("token_key")
}
}

View File

@ -0,0 +1,91 @@
package com.example.api.core.data.core
import com.example.api.core.data.dto.AuthUserResponseDto
import com.example.api.core.data.dto.RegisterUserDto
import com.example.api.core.data.dto.SaleItemDto
import com.example.api.core.data.dto.ProjectsDto
import com.example.api.core.data.dto.SignInUserDto
import com.example.api.core.data.dto.SignInUserResponseDto
import com.example.api.core.domain.AuthUserResponse
import com.example.api.core.domain.RegisterUser
import com.example.api.core.domain.SaleItem
import com.example.api.core.domain.Projects
import com.example.api.core.domain.SignInUserResponse
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
/**
Маппер для преобразования объектов сервера в доменные
* */
internal abstract class RemoteMapper {
protected fun RegisterUser.toRegisterUserDto(): RegisterUserDto {
return RegisterUserDto(
email = email,
password = password,
passwordConfirm = passwordConfirm
)
}
protected fun RegisterUser.toSignInUserDto(): SignInUserDto {
return SignInUserDto(
identity = email,
password = password
)
}
protected fun AuthUserResponseDto.toUserResponse(): AuthUserResponse {
return AuthUserResponse(
record = record.toUserResponse(),
token = token
)
}
protected fun SignInUserResponseDto.toUserResponse(): SignInUserResponse {
return SignInUserResponse(
collectionId = collectionId,
collectionName = collectionName,
created = created,
dateBirthday = dateBirthday,
emailVisibility = emailVisibility,
email = email,
firstname = firstname,
gender = gender,
id = id,
lastname = lastname,
secondname = secondname,
updated = updated,
verified = verified
)
}
protected fun ProjectsDto.toDomain(): Projects {
return Projects(
page = page,
perPage = perPage,
totalPage = totalPages,
totalItems = totalItems,
items = items.map { it.toSaleItem() }
)
}
protected fun SaleItemDto.toSaleItem(): SaleItem {
return SaleItem(
id = id,
collectionId = collectionId,
collectionName = collectionName,
created = created,
updated = updated,
title = title,
dateStart = dateStart,
dateEnd = dateEnd,
gender = gender,
descriptionSource = descriptionSource,
category = category,
image = image,
userId = userId
)
}
}

View File

@ -0,0 +1,9 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class AuthUserResponseDto(
val record: SignInUserResponseDto,
val token: String
)

View File

@ -0,0 +1,12 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class CatalogDto(
val items: List<CatalogItemDto>,
val page: Int,
val perPage: Int,
val totalItems: Int,
val totalPages: Int
)

View File

@ -0,0 +1,19 @@
package com.example.api.core.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class CatalogItemDto(
@SerialName("approximate_cost") val approximateCost: String,
val collectionId: String,
val collectionName: String,
val created: String,
val description: String,
val id: String,
val price: Int,
val title: String,
val type: String,
val typeCloses: String,
val updated: String
)

View File

@ -0,0 +1,12 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class ProjectsDto(
val page: Int,
val perPage: Int,
val totalPages: Int,
val totalItems: Int,
val items: List<SaleItemDto>
)

View File

@ -0,0 +1,10 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class RegisterUserDto(
val email: String,
val password: String,
val passwordConfirm: String
)

View File

@ -0,0 +1,21 @@
package com.example.api.core.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
internal data class SaleItemDto(
val id: String,
val collectionId: String,
val collectionName: String,
val created: String,
val updated: String,
val title: String,
val dateStart: String,
val dateEnd: String,
val gender: String,
@SerialName("description_source") val descriptionSource: String,
val category: String,
val image: String,
@SerialName("user_id") val userId: String
)

View File

@ -0,0 +1,12 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class SalesAndNewsDto(
val items: List<SalesAndNewsItemDto>,
val page: Int,
val perPage: Int,
val totalItems: Int,
val totalPages: Int
)

View File

@ -0,0 +1,13 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class SalesAndNewsItemDto(
val collectionId: String,
val collectionName: String,
val created: String,
val id: String,
val newsImage: String,
val updated: String
)

View File

@ -0,0 +1,9 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class SignInUserDto(
val identity: String,
val password: String
)

View File

@ -0,0 +1,20 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
data class SignInUserResponseDto(
val collectionId: String,
val collectionName: String,
val created: String,
val dateBirthday: String,
val email: String,
val emailVisibility: Boolean,
val firstname: String,
val gender: String,
val id: String,
val lastname: String,
val secondname: String,
val updated: String,
val verified: Boolean
)

View File

@ -0,0 +1,19 @@
package com.example.api.core.data.dto
import kotlinx.serialization.Serializable
@Serializable
internal data class UserResponseDto(
val collectionId: String,
val collectionName: String,
val created: String,
val dateBirthday: String,
val emailVisibility: Boolean,
val firstname: String,
val gender: String,
val id: String,
val lastname: String,
val secondname: String,
val updated: String,
val verified: Boolean
)

View File

@ -0,0 +1,13 @@
package com.example.api.core.domain
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
interface ApiRepository {
suspend fun auth(registerUser: RegisterUser): FetchResult<AuthUserResponse>
suspend fun projects(): FetchResult<Projects>
}

View File

@ -0,0 +1,6 @@
package com.example.api.core.domain
data class AuthUserResponse(
val record: SignInUserResponse,
val token: String
)

View File

@ -0,0 +1,5 @@
package com.example.api.core.domain
data class CatalogResponse(
val items: List<SaleItem>
)

View File

@ -0,0 +1,31 @@
package com.example.api.core.domain
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
interface FetchResult<D> {
fun <Ui> map(mapper: Mapper<D, Ui>): Ui
interface Mapper<D, Ui> {
fun mapSuccess(data: D): Ui
fun mapError(data: D?, message: String): Ui
}
class Success<D>(private val data: D) : FetchResult<D> {
override fun <Ui> map(mapper: Mapper<D, Ui>): Ui {
return mapper.mapSuccess(data)
}
}
class Error<D>(private val data: D?, private val message: String) : FetchResult<D> {
override fun <Ui> map(mapper: Mapper<D, Ui>): Ui {
return mapper.mapError(data, message)
}
}
}

View File

@ -0,0 +1,9 @@
package com.example.api.core.domain
data class Projects(
val page: Int,
val perPage: Int,
val totalPage: Int,
val totalItems: Int,
val items: List<SaleItem>
)

View File

@ -0,0 +1,7 @@
package com.example.api.core.domain
data class RegisterUser(
val email: String,
val password: String,
val passwordConfirm: String
)

View File

@ -0,0 +1,17 @@
package com.example.api.core.domain
data class SaleItem(
val id: String,
val collectionId: String,
val collectionName: String,
val created: String,
val updated: String,
val title: String,
val dateStart: String,
val dateEnd: String,
val gender: String,
val descriptionSource: String,
val category: String,
val image: String,
val userId: String
)

View File

@ -0,0 +1,6 @@
package com.example.api.core.domain
data class SignInUser(
val identity: String,
val password: String
)

View File

@ -0,0 +1,17 @@
package com.example.api.core.domain
data class SignInUserResponse(
val collectionId: String,
val collectionName: String,
val created: String,
val dateBirthday: String,
val emailVisibility: Boolean,
val email: String,
val firstname: String,
val gender: String,
val id: String,
val lastname: String,
val secondname: String,
val updated: String,
val verified: Boolean
)

View File

@ -0,0 +1,16 @@
package com.example.api.core.domain
data class UserResponse(
val collectionId: String,
val collectionName: String,
val created: String,
val dateBirthday: String,
val emailVisibility: Boolean,
val firstname: String,
val gender: String,
val id: String,
val lastname: String,
val secondname: String,
val updated: String,
val verified: Boolean
)

View File

@ -0,0 +1,336 @@
package com.example.api.core
import com.example.api.core.data.core.ApiService
import com.example.api.core.data.dto.RegisterUserDto
import com.example.api.core.data.dto.SignInUserDto
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.create
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
/**
* Общая структура тестов
* фейк ответ
* сверка с dto некоторые параметры
* проверка типа запроса
* проверка пути запроса
* опционально параметры и указание пути*/
class ServiceTest {
private lateinit var mockWebServer: MockWebServer
private lateinit var service: ApiService
@Before
fun setup() {
mockWebServer = MockWebServer()
mockWebServer.start()
val json = Json { ignoreUnknownKeys = true }
service = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.client(OkHttpClient.Builder().addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}).build())
.build().create<ApiService>()
}
@Test
fun test_registration() = runBlocking {
val response = """
{
"collectionId": "_pb_users_auth_",
"collectionName": "users",
"created": "2025-05-27 06:08:13.833Z",
"dateBirthday": "",
"emailVisibility": false,
"firstname": "",
"gender": "",
"id": "w20q0pj1e2824oz",
"lastname": "",
"secondname": "",
"updated": "2025-05-27 06:08:13.833Z",
"verified": false
}
""".trimIndent()
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response))
val responseResult = service.register(
RegisterUserDto(
email = "email@test.com",
password = "12345678",
passwordConfirm = "12345678"
)
)
val request = mockWebServer.takeRequest()
assertEquals("w20q0pj1e2824oz", responseResult.id)
assertEquals("2025-05-27 06:08:13.833Z", responseResult.created)
assertEquals("POST", request.method)
assertEquals("/collections/users/records", request.path)
}
@Test
fun test_sign_in() = runBlocking {
val response = """
{
"record": {
"collectionId": "_pb_users_auth_",
"collectionName": "users",
"created": "2025-05-27 06:08:13.833Z",
"dateBirthday": "",
"email": "example123@test.ru",
"emailVisibility": false,
"firstname": "",
"gender": "",
"id": "w20q0pj1e2824oz",
"lastname": "",
"secondname": "",
"updated": "2025-05-27 06:08:13.833Z",
"verified": false
},
"token": "eyJhbGciOiJIUzIqwwe1NiIsInR5cCI6IkpXVCJg9.eyJjb2xsZWN0aW9uSWheQiOiJfqcGggJfdXNlcnNfYXV0aFasd8iLCJleHAiOjE3NDg5MzA5MjAsImlkIjoidzIwcTBwajFlMjgyNG96IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.7Sc_NB_CgnwEfXWp0NjsnDT35gGVXzFJwwLUVD730Gw"
}
""".trimIndent()
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response))
val responseResult = service.auth(
SignInUserDto(
identity = "example123@test.ru",
password = "12345678"
)
)
val request = mockWebServer.takeRequest()
assertEquals("example123@test.ru", responseResult.record.email)
assertEquals(
"eyJhbGciOiJIUzIqwwe1NiIsInR5cCI6IkpXVCJg9.eyJjb2xsZWN0aW9uSWheQiOiJfqcGggJfdXNlcnNfYXV0aFasd8iLCJleHAiOjE3NDg5MzA5MjAsImlkIjoidzIwcTBwajFlMjgyNG96IiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.7Sc_NB_CgnwEfXWp0NjsnDT35gGVXzFJwwLUVD730Gw",
responseResult.token
)
assertEquals("POST", request.method)
assertEquals("/collections/users/auth-with-password", request.path)
}
@Test
fun test_project_list() = runBlocking {
val response = """
{
"items": [
{
"category": "е",
"collectionId": "pbc_3202395908",
"collectionName": "project",
"created": "2025-05-26 14:47:25.523Z",
"dateEnd": "2025-05-24 12:00:00.000Z",
"dateStart": "2025-05-15 12:00:00.000Z",
"description_source": "е",
"gender": "е",
"id": "7m35pcmbjr86kh4",
"image": "",
"title": "е",
"typeProject": "е",
"updated": "2025-05-26 14:47:25.523Z",
"user_id": ""
}
],
"page": 1,
"perPage": 30,
"totalItems": 1,
"totalPages": 1
}
""".trimIndent()
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response))
val responseResult = service.projects()
val request = mockWebServer.takeRequest()
assertEquals(1, responseResult.page)
assertEquals(1, responseResult.totalItems)
assertEquals(1, responseResult.items.size)
assertEquals("GET", request.method)
assertEquals("/collections/project/records", request.path)
}
@Test
fun test_sales_and_news() = runBlocking {
val response = """
{
"items": [
{
"collectionId": "pbc_2599178718",
"collectionName": "News",
"created": "2025-05-26 14:21:46.284Z",
"id": "yxe08bp4woz996w",
"newsImage": "banner_u9uqsy4sf9.png",
"updated": "2025-05-26 15:29:41.083Z"
},
{
"collectionId": "pbc_2599178718",
"collectionName": "News",
"created": "2025-05-26 15:29:50.831Z",
"id": "lqg3hpgn46efgmz",
"newsImage": "banner_2_10pjar2fq7.png",
"updated": "2025-05-26 15:29:50.831Z"
}
],
"page": 1,
"perPage": 30,
"totalItems": 2,
"totalPages": 1
}
""".trimIndent()
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response))
val responseResult = service.salesAndNews()
val request = mockWebServer.takeRequest()
assertEquals(2, responseResult.items.size)
assertEquals("News", responseResult.items.first().collectionName)
assertEquals("lqg3hpgn46efgmz", responseResult.items[1].id)
assertEquals("GET", request.method)
assertEquals("/collections/news/records", request.path)
}
@Test
fun test_catalog_all() = runBlocking {
val response = """
{
"items": [
{
"approximate_cost": "0",
"collectionId": "pbc_4092854851",
"collectionName": "products",
"created": "2025-05-26 15:44:16.919Z",
"description": "Мой выбор для этих шапок кардные составы, которые раскрываются деликатным пушком. Кашемиры, мериносы, смесовки с ними отлично подойдут на шапку.\r\nКардные составы берите в большое количество сложений, вязать будем резинку 1х1, плотненько.\r\nПряжу 1400-1500м в 100г в 4 сложения, пряжу 700м в 2 сложения. Ориентир для конечной ",
"id": "45urlx4rj907rrk",
"price": 350,
"title": "Рубашка Среда для машинного \r\nвязания",
"type": "Женская одежда",
"typeCloses": "Женщинам",
"updated": "2025-05-26 15:44:16.919Z"
}
],
"page": 1,
"perPage": 30,
"totalItems": 1,
"totalPages": 1
}
""".trimIndent()
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response))
val responseResult = service.catalog()
val request = mockWebServer.takeRequest()
assertEquals(1, responseResult.items.size)
assertEquals("Женщинам", responseResult.items.first().typeCloses)
assertEquals(30, responseResult.perPage)
assertEquals("GET", request.method)
assertEquals("/collections/products/records", request.path)
}
@Test
fun test_catalog_with_filter() = runBlocking {
val response = """
{
"items": [
{
"approximate_cost": "0",
"collectionId": "pbc_4092854851",
"collectionName": "products",
"created": "2025-05-26 15:44:16.919Z",
"description": "Мой выбор для этих шапок кардные составы, которые раскрываются деликатным пушком. Кашемиры, мериносы, смесовки с ними отлично подойдут на шапку.\r\nКардные составы берите в большое количество сложений, вязать будем резинку 1х1, плотненько.\r\nПряжу 1400-1500м в 100г в 4 сложения, пряжу 700м в 2 сложения. Ориентир для конечной ",
"id": "45urlx4rj907rrk",
"price": 350,
"title": "Рубашка Среда для машинного \r\nвязания",
"type": "Женская одежда",
"typeCloses": "Женщинам",
"updated": "2025-05-26 15:44:16.919Z"
}
],
"page": 1,
"perPage": 30,
"totalItems": 1,
"totalPages": 1
}
""".trimIndent()
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response))
val responseResult = service.catalog("(typeCloses ?~ 'Женщинам')")
val request = mockWebServer.takeRequest()
assertEquals(1, responseResult.items.size)
assertEquals("Женщинам", responseResult.items.first().typeCloses)
assertEquals(30, responseResult.perPage)
assertEquals("GET", request.method)
assertEquals("(typeCloses ?~ 'Женщинам')", request.requestUrl!!.queryParameter("filter"))
}
@Test
fun test_product_description() = runBlocking {
val response = """
{
"approximate_cost": "0",
"collectionId": "pbc_4092854851",
"collectionName": "products",
"created": "2025-05-26 15:44:16.919Z",
"description": "Мой выбор для этих шапок кардные составы, которые раскрываются деликатным пушком. Кашемиры, мериносы, смесовки с ними отлично подойдут на шапку.\r\nКардные составы берите в большое количество сложений, вязать будем резинку 1х1, плотненько.\r\nПряжу 1400-1500м в 100г в 4 сложения, пряжу 700м в 2 сложения. Ориентир для конечной ",
"id": "45urlx4rj907rrk",
"price": 350,
"title": "Рубашка Среда для машинного \r\nвязания",
"type": "Женская одежда",
"typeCloses": "Женщинам",
"updated": "2025-05-26 15:44:16.919Z"
}
""".trimIndent()
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(response))
val productId = "45urlx4rj907rrk"
val responseResult = service.productDescription(productId)
val request = mockWebServer.takeRequest()
assertEquals("0", responseResult.approximateCost)
assertEquals("Женская одежда", responseResult.type)
assertEquals("GET", request.method)
assertEquals("/collections/products/records/$productId", request.path)
}
@After
fun teardown() {
mockWebServer.shutdown()
}
}

View File

@ -2,6 +2,8 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.dagger.hilt.android)
kotlin("kapt")
}
android {
@ -40,6 +42,10 @@ android {
}
dependencies {
implementation(libs.androidx.datastore.preferences)
kapt(libs.hilt.android.compiler)
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@ -56,4 +62,6 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(project(":api-core"))
}

View File

@ -2,7 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View File

@ -0,0 +1,12 @@
package com.example.api
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
@HiltAndroidApp
class App : Application()

View File

@ -0,0 +1,25 @@
package com.example.api
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
abstract class BaseViewModel : ViewModel() {
protected fun<T> handle(action: suspend () -> T, ui: (T) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val result = action.invoke()
withContext(Dispatchers.Main) {
ui.invoke(result)
}
}
}
}

View File

@ -0,0 +1,51 @@
package com.example.api
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.example.api.core.data.core.ApiFactory
import com.example.api.core.data.core.DataStoreRepository
import com.example.api.core.domain.ApiRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
@Module
@InstallIn(SingletonComponent::class)
class CoreModule {
@Provides
@Singleton
fun provideContent(@ApplicationContext context: Context): Context = context
@Provides
@Singleton
fun provideDataStore(context: Context): DataStore<Preferences> {
return context.dataStore
}
@Provides
@Singleton
fun provideDataStoreRepository(preferences: DataStore<Preferences>): DataStoreRepository {
return DataStoreRepository.Base(preferences)
}
@Provides
@Singleton
fun provideRepository(dataStoreRepository: DataStoreRepository): ApiRepository {
return ApiFactory.provideRepository(dataStoreRepository)
}
companion object {
private val Context.dataStore by preferencesDataStore("settings")
}
}

View File

@ -0,0 +1,18 @@
package com.example.api
import com.example.api.core.domain.FetchResult
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
class FetchResultMapper<T> : FetchResult.Mapper<T, FetchResultUiState<T>> {
override fun mapSuccess(data: T): FetchResultUiState<T> {
return FetchResultUiState.Success(data)
}
override fun mapError(data: T?, message: String): FetchResultUiState<T> {
return FetchResultUiState.Error(data, message)
}
}

View File

@ -0,0 +1,62 @@
package com.example.api
import androidx.compose.runtime.Composable
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
interface FetchResultUiState<T> {
@Composable
fun Show(
onSuccess: @Composable (T) -> Unit,
onError: @Composable (T?, String) -> Unit,
onLoading: @Composable (T?) -> Unit
)
class Success<T>(val data: T) : FetchResultUiState<T> {
@Composable
override fun Show(
onSuccess: @Composable (T) -> Unit,
onError: @Composable (T?, String) -> Unit,
onLoading: @Composable (T?) -> Unit
) {
onSuccess(data)
}
}
class Error<T>(private val data: T?, private val message: String) : FetchResultUiState<T> {
@Composable
override fun Show(
onSuccess: @Composable (T) -> Unit,
onError: @Composable (T?, String) -> Unit,
onLoading: @Composable (T?) -> Unit
) {
onError(data, message)
}
}
class Loading<T>(private val data: T? = null) : FetchResultUiState<T> {
@Composable
override fun Show(
onSuccess: @Composable (T) -> Unit,
onError: @Composable (T?, String) -> Unit,
onLoading: @Composable (T?) -> Unit
) {
onLoading(data)
}
}
class Initial<T> : FetchResultUiState<T> {
@Composable
override fun Show(
onSuccess: @Composable (T) -> Unit,
onError: @Composable (T?, String) -> Unit,
onLoading: @Composable (T?) -> Unit
) {
}
}
}

View File

@ -4,44 +4,18 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.example.api.ui.theme.ApiTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ApiTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
TestScreen()
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ApiTheme {
Greeting("Android")
}
}

View File

@ -0,0 +1,56 @@
package com.example.api
import com.example.api.core.domain.ApiRepository
import com.example.api.core.domain.AuthUserResponse
import com.example.api.core.domain.Projects
import com.example.api.core.domain.RegisterUser
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
@HiltViewModel
class MainViewModel @Inject constructor(
private val apiRepository: ApiRepository
) : BaseViewModel() {
private val _authUiState =
MutableStateFlow<FetchResultUiState<AuthUserResponse>>(FetchResultUiState.Initial())
val authUiState: StateFlow<FetchResultUiState<AuthUserResponse>>
get() = _authUiState.asStateFlow()
private val _projectsUiState =
MutableStateFlow<FetchResultUiState<Projects>>(FetchResultUiState.Initial())
val projectsUiState: StateFlow<FetchResultUiState<Projects>>
get() = _projectsUiState.asStateFlow()
fun auth(email: String, password: String) {
handle(
action = {
apiRepository.auth(
RegisterUser(
email = email,
password = password,
passwordConfirm = password
)
)
}
) {
_authUiState.value = it.map(FetchResultMapper())
}
}
fun salesAndProjects() {
handle(
action = { apiRepository.projects() }
) {
_projectsUiState.value = it.map(FetchResultMapper())
}
}
}

View File

@ -0,0 +1,9 @@
package com.example.api
data class SalesAndNews(
val items: List<SalesAndNewsItem>,
val page: Int,
val perPage: Int,
val totalItems: Int,
val totalPages: Int
)

View File

@ -0,0 +1,10 @@
package com.example.api
data class SalesAndNewsItem(
val collectionId: String,
val collectionName: String,
val created: String,
val id: String,
val newsImage: String,
val updated: String
)

View File

@ -0,0 +1,52 @@
package com.example.api
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
/**
* Автор: Манякин Дмитрий (user5)
* Дата создания: 27.05.2025
* */
@Composable
fun TestScreen(
modifier: Modifier = Modifier,
viewModel: MainViewModel = hiltViewModel()
) {
val state by viewModel.authUiState.collectAsState()
val state2 by viewModel.projectsUiState.collectAsState()
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column {
Button(onClick = { viewModel.auth("someemael@te3s11t1.com", "12345678") }) {
Text("register")
}
state.Show(
onSuccess = { Text(text = it.token) },
onError = { _, message -> Text(text = message) },
onLoading = { CircularProgressIndicator() }
)
Button(onClick = { viewModel.salesAndProjects() }) {
Text("sales and projects")
}
state2.Show(
onSuccess = { Text(text = it.items.size.toString()) },
onError = { _, message -> Text(text = message) },
onLoading = { CircularProgressIndicator() }
)
}
}
}

View File

@ -3,4 +3,6 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.dagger.hilt.android) apply false
}

View File

@ -8,8 +8,30 @@ espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.9.0"
activityCompose = "1.10.1"
composeBom = "2024.09.00"
appcompat = "1.7.0"
material = "1.12.0"
retrofit = "2.11.0"
okhttp3 = "4.12.0"
kotlinxSerializationConverter = "1.0.0"
kotlinxSerializationJson = "1.8.0"
kotlinxCoroutinesTest = "1.10.2"
datastorePreferences = "1.1.6"
dagger = "2.55"
hiltNavigationCompose = "1.2.0"
[libraries]
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp3" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
retrofit2-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "kotlinxSerializationConverter" }
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp3" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp3" }
retrofit2 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@ -24,9 +46,14 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[plugins]
dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }

View File

@ -21,4 +21,4 @@ dependencyResolutionManagement {
rootProject.name = "api"
include(":app")
include(":api-core")