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.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.dagger.hilt.android)
kotlin("kapt")
} }
android { android {
@ -40,6 +42,10 @@ android {
} }
dependencies { 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.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@ -56,4 +62,6 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) 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" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".App"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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 com.example.api.ui.theme.ApiTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
ApiTheme { ApiTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> TestScreen()
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
} }
} }
} }
} }
}
@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.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) 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" lifecycleRuntimeKtx = "2.9.0"
activityCompose = "1.10.1" activityCompose = "1.10.1"
composeBom = "2024.09.00" 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] [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" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } 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] [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" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" rootProject.name = "api"
include(":app") include(":app")
include(":api-core")