openstore pre release

This commit is contained in:
mi6e4ka 2025-08-31 22:49:11 +03:00
parent 29eaadf629
commit 5e66f44c1a
No known key found for this signature in database
GPG key ID: 2070C80A0E25C7F8
48 changed files with 2172 additions and 462 deletions

9
.gitignore vendored
View file

@ -1,15 +1,12 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea
/.kotlin
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/release

3
.idea/.gitignore generated vendored
View file

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-06-30T15:13:37.033573869Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/mi6e4ka/.android/avd/Pixel_8a.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated
View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -1,61 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

10
.idea/migrations.xml generated
View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml generated
View file

@ -1,9 +0,0 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -6,26 +6,47 @@ plugins {
android {
namespace = "dev.mi6e4ka.openstore"
compileSdk = 35
compileSdk = 36
androidResources {
localeFilters.addAll(arrayOf("ru", "en"))
}
defaultConfig {
applicationId = "dev.mi6e4ka.openstore"
minSdk = 21
targetSdk = 35
versionCode = 1
versionName = "1.0"
targetSdk = 36
versionCode = 6
versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
create("nightly") {
versionNameSuffix = "-nightly"
applicationIdSuffix = ".nightly"
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
isMinifyEnabled = true
isShrinkResources = true
resValue("string", "app_name", "OpenStore Nightly")
}
debug {
versionNameSuffix = "-debug"
applicationIdSuffix = ".debug"
resValue("string", "app_name", "OpenStore Debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
@ -36,6 +57,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}
@ -47,8 +69,14 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material.icons.extended.android)
implementation(libs.accompanist.pager)
implementation(libs.accompanist.pager.indicators)
implementation(libs.androidx.runtime.livedata)
implementation(libs.material3)
debugImplementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.okhttp3.logging.interceptor)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View file

@ -18,4 +18,4 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile

View file

@ -1,24 +0,0 @@
package dev.mi6e4ka.openstore
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 ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("dev.mi6e4ka.openstore", appContext.packageName)
}
}

View file

@ -3,6 +3,15 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -12,18 +21,38 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenStore"
tools:targetApi="31">
tools:targetApi="36">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.OpenStore">
<!-- openstore deeplink, haha -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:host="rustore.ru" />
<data android:host="www.rustore.ru" />
<data android:host="apps.rustore.ru" />
<data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver
android:name=".internal.installer.PackageInstallerStatusReceiver"
android:exported="false" />
</application>
</manifest>

View file

@ -3,173 +3,133 @@ package dev.mi6e4ka.openstore
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.EaseIn
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandIn
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkOut
import androidx.compose.animation.slideIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOut
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavArgument
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import dev.mi6e4ka.openstore.ui.theme.OpenStoreTheme
import androidx.navigation.navDeepLink
import coil.Coil
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import dev.mi6e4ka.openstore.ui.screen.details.DetailsScreen
import dev.mi6e4ka.openstore.ui.screen.results.ResultsScreen
import dev.mi6e4ka.openstore.ui.screen.reviews.ReviewsScreen
import dev.mi6e4ka.openstore.ui.screen.search.SearchScreen
import dev.mi6e4ka.openstore.ui.theme.Theme
class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
}
override fun onCreate(savedInstanceState: Bundle?) {
val cacheDir = externalCacheDir ?: return
cacheDir.listFiles()?.forEach { file ->
if (file.isFile && file.name.endsWith(".apk", ignoreCase = true)) {
file.delete()
println("CacheCleaner" + "Удален: ${file.name}")
}
}
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val imageLoader = ImageLoader.Builder(this)
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(10L * 1024 * 1024)
.build()
}
.memoryCache {
MemoryCache.Builder(this)
.maxSizeBytes(10 * 1024 * 1024)
.build()
}
.networkCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build()
Coil.setImageLoader(imageLoader)
setContent {
OpenStoreTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "search") {
composable("search") {
MainPage(onSearch = {search ->
navController.navigate("search/$search")
})
}
composable("search/{query}",
arguments = listOf(
navArgument("query") {
defaultValue = ""
}
)
) { entry ->
val query = entry.arguments?.getString("query")
SearchScreen(navController=navController, query=query)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(navController: NavController, query: String?) {
data class AppInfo(
val id: String,
val name: String,
val iconUrl: String
)
Scaffold { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
TopAppBar(
title = {Text("Поиск: $query")},
navigationIcon = {
IconButton(
onClick = {navController.navigateUp()}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
)
}
}
)
Text("goida search $query")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainPage(onSearch: (String) -> Unit) {
var search by remember { mutableStateOf("") }
Scaffold(
modifier = Modifier.fillMaxSize(),
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(bottom=10.dp, start=10.dp, end=10.dp, top=0.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column {
TopAppBar(title = {Text(stringResource(R.string.app_name))})
Card(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp, 0.dp)
.height(50.dp)
Theme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 10.dp)
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "search",
enterTransition = {
fadeIn(
animationSpec = tween(
250, easing = LinearEasing
)
)/* + slideIntoContainer(
animationSpec = tween(300, easing = EaseIn),
towards = AnimatedContentTransitionScope.SlideDirection.Start
)*/
},
exitTransition = {
fadeOut(
animationSpec = tween(
250, easing = LinearEasing
)
)/* + slideOutOfContainer(
animationSpec = tween(300, easing = EaseOut),
towards = AnimatedContentTransitionScope.SlideDirection.End
)*/
}
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(32.dp),
)
Text(stringResource(R.string.safety_card_text), fontSize = 18.sp)
composable("search") {
SearchScreen(navController)
}
composable("search/{query}") {
val query = it.arguments?.getString("query") ?: ""
ResultsScreen(query = query, navController = navController)
}
composable("app/{appId}", deepLinks = listOf(
navDeepLink { uriPattern = "https://www.rustore.ru/catalog/app/{appId}" },
navDeepLink { uriPattern = "https://apps.rustore.ru/app/{appId}" },
navDeepLink { uriPattern = "https://www.rustore.ru/instruction?appName={appName}&utm_campaign={appId}" }
)) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("appId") ?: ""
AnimatedVisibility(
visible = true,
enter = slideInHorizontally(initialOffsetX = { 1000 }) + fadeIn(),
exit = slideOutHorizontally(targetOffsetX = { -1000 }) + fadeOut()
) {
DetailsScreen(itemId = itemId, navController = navController)
}
}
composable("app/{packageName}/reviews") {
val packageName = it.arguments?.getString("packageName") ?: ""
ReviewsScreen(packageName = packageName, navController = navController)
}
}
}
}
OutlinedTextField(
value = search,
onValueChange = {s -> search=s},
leadingIcon = { Icon(imageVector = Icons.Default.Search, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = {
onSearch(search)
}
)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("source code")
Text("v1.4.0")
}
}
}
}

View file

@ -0,0 +1,29 @@
package dev.mi6e4ka.openstore.data.api
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import okhttp3.logging.HttpLoggingInterceptor
object ApiClient {
private const val BASE_URL = "https://backapi.rustore.ru/"
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.NONE
}
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service: ApiService = retrofit.create(ApiService::class.java)
}

View file

@ -0,0 +1,35 @@
package dev.mi6e4ka.openstore.data.api
import dev.mi6e4ka.openstore.data.model.AppCommentsResponse
import dev.mi6e4ka.openstore.data.model.AppRatingResponse
import dev.mi6e4ka.openstore.data.model.DownloadLinkRequest
import dev.mi6e4ka.openstore.data.model.DownloadLinkResponse
import dev.mi6e4ka.openstore.data.model.ItemDetailsResponse
import dev.mi6e4ka.openstore.data.model.SearchResponse
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface ApiService {
@GET("applicationData/apps")
suspend fun search(@Query("pageSize") pageSize: Int, @Query("query") query: String): SearchResponse
@GET("applicationData/overallInfo/{packageName}")
suspend fun getItemDetails(@Path("packageName") packageName: String): ItemDetailsResponse
@POST("applicationData/v2/download-link")
suspend fun getAppDownloadLink(@Body request: DownloadLinkRequest) : DownloadLinkResponse
@GET("comment/findRating")
suspend fun getAppRating(@Query("packageName") packageName: String) : AppRatingResponse
@GET("https://backapi.rustore.ru/comment/comment")
suspend fun getAppComments(
@Query("packageName") packageName: String,
@Query("pageNumber") pageNumber: Int = 0,
@Query("pageSize") pageSize: Int,
@Query("sortBy") sortBy: String,
) : AppCommentsResponse
}

View file

@ -0,0 +1,23 @@
package dev.mi6e4ka.openstore.data.model
import com.google.gson.annotations.SerializedName
data class AppCommentsResponse(
@SerializedName("body") val body: AppCommentsResponseBody,
)
data class AppCommentsResponseBody(
@SerializedName("content") val content: List<AppCommentsComments>,
)
data class AppCommentsComments(
@SerializedName("appRating") val appRating: Int,
@SerializedName("firstName") val firstName: String,
@SerializedName("commentDate") val commentDate: String,
@SerializedName("commentText") val commentText: String,
@SerializedName("likeCounter") val likeCounter: Int,
@SerializedName("dislikeCounter") val dislikeCounter: Int,
@SerializedName("updatedAt") val updatedAt: String,
@SerializedName("devResponseDate") val devResponseDate: String,
@SerializedName("devResponse") val devResponse: String
)

View file

@ -0,0 +1,22 @@
package dev.mi6e4ka.openstore.data.model
import com.google.gson.annotations.SerializedName
data class AppRatingResponse(
@SerializedName("body") val body: AppRatingResponseBody,
)
data class AppRatingResponseBody(
@SerializedName("ratings") val ratings: AppRatingCount,
@SerializedName("averageUserRating") val averageUserRating: Float,
@SerializedName("totalRatings") val totalRatings: Int,
)
data class AppRatingCount(
@SerializedName("amountFive") val amountFive: Int,
@SerializedName("amountFour") val amountFour: Int,
@SerializedName("amountThree") val amountThree: Int,
@SerializedName("amountTwo") val amountTwo: Int,
@SerializedName("amountOne") val amountOne: Int,
)

View file

@ -0,0 +1,23 @@
package dev.mi6e4ka.openstore.data.model
import com.google.gson.annotations.SerializedName
data class DownloadLinkRequest(
@SerializedName("appId") val appId: Int,
@SerializedName("firstInstall") val firstInstall: Boolean,
@SerializedName("screenDensity") val screenDensity: Int,
@SerializedName("sdkVersion") val sdkVersion: Int,
@SerializedName("withoutSplits") val withoutSplits: Boolean,
)
data class DownloadLinkResponse(
@SerializedName("body") val body: DownloadLinkResponseBody,
)
data class DownloadLinkResponseBody(
@SerializedName("downloadUrls") val downloadUrls: List<DownloadLinkResponseItem>,
)
data class DownloadLinkResponseItem(
@SerializedName("url") val url: String,
)

View file

@ -0,0 +1,41 @@
package dev.mi6e4ka.openstore.data.model
import com.google.gson.annotations.SerializedName
data class ItemDetails(
@SerializedName("appId") val appId: Int,
@SerializedName("appName") val appName: String,
@SerializedName("packageName") val packageName: String,
@SerializedName("companyName") val companyName: String,
@SerializedName("iconUrl") val iconUrl: String,
@SerializedName("fullDescription") val fullDescription: String,
@SerializedName("shortDescription") val shortDescription: String,
@SerializedName("fileSize") val fileSize: Float,
@SerializedName("minSdkVersion") val minSdkVersion: Int,
@SerializedName("targetSdkVersion") val targetSdkVersion: Int,
@SerializedName("versionName") val versionName: String,
@SerializedName("versionCode") val versionCode: Long,
@SerializedName("fileUrls") val fileUrls: List<Files>,
@SerializedName("aggregatorInfo") val aggregatorInfo: AggregatorInfo?,
@SerializedName("roundedDownloadsText") val roundedDownloadsText: String,
@SerializedName("ageLegal") val ageLegal: String,
@SerializedName("whatsNew") val whatsNew: String,
)
data class Files(
@SerializedName("fileUrl") val fileUrl: String,
@SerializedName("ordinal") val ordinal: Int,
@SerializedName("type") val type: String,
@SerializedName("orientation") val orientation: String,
)
data class AggregatorInfo(
@SerializedName("companyName") val companyName: String,
@SerializedName("source") val source: String,
)
data class ItemDetailsResponse(
@SerializedName("code") val code: String,
@SerializedName("message") val message: String?,
@SerializedName("body") val body: ItemDetails,
)

View file

@ -0,0 +1,20 @@
package dev.mi6e4ka.openstore.data.model
import com.google.gson.annotations.SerializedName
data class SearchResult(
@SerializedName("appId") val appId: Int,
@SerializedName("appName") val appName: String,
@SerializedName("packageName") val packageName: String,
@SerializedName("iconUrl") val iconUrl: String,
)
data class SearchResponseBody(
@SerializedName("content") val content: List<SearchResult>
)
data class SearchResponse(
@SerializedName("code") val code: String,
@SerializedName("message") val message: String?,
@SerializedName("body") val body: SearchResponseBody
)

View file

@ -0,0 +1,33 @@
package dev.mi6e4ka.openstore.data.repository
import dev.mi6e4ka.openstore.data.api.ApiClient
import dev.mi6e4ka.openstore.data.model.AppCommentsComments
import dev.mi6e4ka.openstore.data.model.AppRatingResponseBody
import dev.mi6e4ka.openstore.data.model.DownloadLinkRequest
import dev.mi6e4ka.openstore.data.model.ItemDetails
import dev.mi6e4ka.openstore.data.model.SearchResult
class SearchRepository {
private val apiService = ApiClient.service
suspend fun search(query: String): List<SearchResult> {
return apiService.search(20, query).body.content
}
suspend fun getItemDetails(id: String): ItemDetails {
return apiService.getItemDetails(id).body
}
suspend fun getAppDownloadLink(id: Int): String {
val req = DownloadLinkRequest(appId = id, firstInstall = true, screenDensity = 420, sdkVersion = 36, withoutSplits = true)
return apiService.getAppDownloadLink(req).body.downloadUrls[0].url
}
suspend fun getAppRating(packageName: String): AppRatingResponseBody {
return apiService.getAppRating(packageName).body
}
suspend fun getAppComments(packageName: String, pageSize: Int, sortBy: String): List<AppCommentsComments> {
return apiService.getAppComments(packageName, pageSize = pageSize, sortBy = sortBy).body.content
}
}

View file

@ -0,0 +1,14 @@
package dev.mi6e4ka.openstore.di
import dev.mi6e4ka.openstore.data.repository.SearchRepository
import dev.mi6e4ka.openstore.internal.installer.UninstallerEventFlow
object AppModule {
val searchRepository: SearchRepository by lazy {
SearchRepository()
}
val uninstallerEventFlow: UninstallerEventFlow by lazy {
UninstallerEventFlow()
}
}

View file

@ -0,0 +1,147 @@
package dev.mi6e4ka.openstore.internal.installer
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.ResponseBody
import java.io.IOException
class ApkInstaller(private val context: Context) {
companion object {
private val okHttpClient = OkHttpClient()
}
suspend fun downloadApkWithProgress(
url: String,
onProgress: (percent: Int, downloaded: Long, total: Long) -> Unit
): File = withContext(Dispatchers.IO) {
val fileName = url.split("/").last()
val apkFile = getApkFile(fileName)
// Проверяем, не скачан ли уже файл
if (isApkAlreadyDownloaded(fileName)) {
return@withContext apkFile
}
val request = Request.Builder()
.url(url)
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Ошибка загрузки: ${response.code}")
}
val responseBody = response.body
if (responseBody == null) {
throw IOException("Пустой ответ от сервера")
}
downloadToFileWithProgress(responseBody, apkFile, onProgress)
}
apkFile
}
private fun downloadToFileWithProgress(
responseBody: ResponseBody,
file: File,
onProgress: (percent: Int, downloaded: Long, total: Long) -> Unit
) {
val totalBytes = responseBody.contentLength()
var downloadedBytes = 0L
responseBody.byteStream().use { inputStream ->
FileOutputStream(file).use { outputStream ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
// Вычисляем прогресс
val progress = if (totalBytes > 0) {
(downloadedBytes * 100 / totalBytes).toInt()
} else {
0
}
// Отправляем прогресс
onProgress(progress, downloadedBytes, totalBytes)
}
}
}
}
fun isApkAlreadyDownloaded(fileName: String): Boolean {
val apkFile = getApkFile(fileName)
return apkFile.exists() && apkFile.length() > 0
}
fun getApkFile(fileName: String): File {
return File(context.externalCacheDir, fileName)
}
fun deleteApkFile(fileName: String) {
val apkFile = getApkFile(fileName)
if (apkFile.exists()) {
apkFile.delete()
}
}
fun getApkFileSize(fileName: String): Long {
return getApkFile(fileName).length()
}
private val installer = context.packageManager.packageInstaller
private val flags = PendingIntent.FLAG_MUTABLE
private val intent = Intent(context, PackageInstallerStatusReceiver::class.java)
private val sessionParams =
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
// //sdkAbove(sdk = Build.VERSION_CODES.S) {
// setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
// //}
// //sdkAbove(sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// setRequestUpdateOwnership(true)
// //}
}
fun installApk(context: Context, apkFile: File, installerCallback: PackageInstaller.SessionCallback) {
val id = installer.createSession(sessionParams)
installer.registerSessionCallback(
installerCallback,
Handler(Looper.getMainLooper())
)
val session = installer.openSession(id)
session.use { activeSession ->
val sizeBytes = apkFile.length()
apkFile.inputStream().use { fileStream ->
activeSession.openWrite(apkFile.name, 0, sizeBytes).use { outputStream ->
fileStream.copyTo(outputStream)
activeSession.fsync(outputStream)
}
}
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, flags)
activeSession.commit(pendingIntent.intentSender)
}
}
}

View file

@ -0,0 +1,42 @@
package dev.mi6e4ka.openstore.internal.installer
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.widget.Toast
import dev.mi6e4ka.openstore.di.AppModule
class PackageInstallerStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
val isUninstall = intent.getBooleanExtra(ACTION_UNINSTALL, false)
when (status) {
PackageInstaller.STATUS_SUCCESS -> {
Toast.makeText(context, if (isUninstall) "Удалено" else "Установлено", Toast.LENGTH_SHORT).show()
if (isUninstall) {
AppModule.uninstallerEventFlow.trigger()
}
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT)
promptIntent?.let {
it.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
it.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending")
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(it)
}
}
else -> {
Toast.makeText(context, if (isUninstall) "Не удалось удалить" else "Не удалось установить", Toast.LENGTH_SHORT).show()
}
}
}
companion object {
const val ACTION_UNINSTALL = "action_uninstall"
}
}

View file

@ -0,0 +1,13 @@
package dev.mi6e4ka.openstore.internal.installer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
class UninstallerEventFlow() {
private val _events = Channel<Unit>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun trigger() {
_events.trySend(Unit)
}
}

View file

@ -0,0 +1,35 @@
package dev.mi6e4ka.openstore.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
@Composable
fun AppIcon(
imageUrl: String,
modifier: Modifier = Modifier
) {
SubcomposeAsyncImage(
model = imageUrl,
contentDescription = "Icon",
modifier = modifier,
loading = {
CircularProgressIndicator(
modifier = Modifier.size(24.dp)
)
},
error = {
Icon(
modifier = Modifier.size(24.dp),
imageVector = Icons.Filled.Error,
contentDescription = "Error"
)
}
)
}

View file

@ -0,0 +1,171 @@
package dev.mi6e4ka.openstore.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import dev.mi6e4ka.openstore.data.model.Files
@Composable
fun GalleryWithPager(images: List<Files>) {
var selectedImageIndex by remember { mutableStateOf<Int?>(null) }
if (selectedImageIndex != null) {
Dialog(
onDismissRequest = { selectedImageIndex = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
FullScreenGallery(
images = images,
initialIndex = selectedImageIndex ?: 0,
onDismiss = { selectedImageIndex = null }
)
}
}
Box(
modifier = Modifier.height(250.dp)
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(images) { i, file ->
var isLoading by remember { mutableStateOf(true) }
AsyncImage(
model = file.fileUrl,
contentDescription = null,
onSuccess = { isLoading = false },
onError = { isLoading = false },
onLoading = { isLoading = true },
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxHeight()
.clickable(onClick = {
selectedImageIndex = i
}),
contentScale = ContentScale.FillHeight
)
if (isLoading) {
Card(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxHeight()
.aspectRatio(9f / 16f),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator()
}
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FullScreenGallery(
images: List<Files>,
initialIndex: Int,
onDismiss: () -> Unit
) {
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { images.size })
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
//contentPadding = PaddingValues(horizontal = 16.dp),
) { page ->
Box(
Modifier
.fillMaxSize()
// .padding(horizontal = ((
// (pagerState.currentPage - page) + pagerState
// .currentPageOffsetFraction
// ).absoluteValue * 0).dp)
// .graphicsLayer {
// val pageOffset = (
// (pagerState.currentPage - page) + pagerState
// .currentPageOffsetFraction
// ).absoluteValue
// alpha = lerp(
// start = 0.5f,
// stop = 1f,
// fraction = 1f - pageOffset.coerceIn(0f, 1f)
// )
// }
) {
Image(
painter = rememberAsyncImagePainter(images[page].fileUrl),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onDismiss),
contentScale = ContentScale.Fit
)
}
}
Row(
Modifier
.wrapContentHeight()
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pagerState.pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.primaryContainer
Box(
modifier = Modifier
.padding(4.dp)
.clip(CircleShape)
.background(color)
.size(8.dp)
)
}
}
}
}

View file

@ -1 +0,0 @@
package dev.mi6e4ka.openstore.ui.nav

View file

@ -1 +0,0 @@
package dev.mi6e4ka.openstore.ui.nav

View file

@ -0,0 +1,493 @@
package dev.mi6e4ka.openstore.ui.screen.details
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material.icons.filled.ArrowCircleUp
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.Api
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Security
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.mi6e4ka.openstore.di.AppModule
import dev.mi6e4ka.openstore.internal.installer.ApkInstaller
import dev.mi6e4ka.openstore.ui.components.AppIcon
import dev.mi6e4ka.openstore.ui.components.GalleryWithPager
import kotlinx.coroutines.flow.update
import androidx.core.net.toUri
import dev.mi6e4ka.openstore.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun DetailsScreen(
itemId: String,
navController: NavController = rememberNavController()
) {
val context = LocalContext.current
val apkInstaller = ApkInstaller(context)
//val viewModel = remember (itemId) { DetailsViewModel(itemId, apkInstaller) }
val viewModel : DetailsViewModel = viewModel(
factory = DetailsViewModelFactory(itemId, apkInstaller)
)
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
var showAppDescriptionSheet by remember { mutableStateOf(false) }
// val appDescriptionSheetState = rememberModalBottomSheetState(
// skipPartiallyExpanded = true
// )
LaunchedEffect(itemId) {
viewModel.loadDetails(context, itemId)
}
LaunchedEffect(Unit) {
println("sub to flow")
AppModule.uninstallerEventFlow.events.collect {
println("FLAG CATCHED!")
viewModel.updateInstallStatus(false)
}
}
Scaffold(
topBar = { TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
actions = {
IconButton(onClick = {
scope.launch {
val clipboard = context.getSystemService(ClipboardManager::class.java)
val link = AppModule.searchRepository.getAppDownloadLink(state.item?.appId ?: 0)
val clip = ClipData.newPlainText("", link)
clipboard.setPrimaryClip(clip)
}
}) {
Icon(painterResource(R.drawable.apk_install_24px), null)
}
IconButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, "https://rustore.ru/catalog/app/${state.item?.packageName ?: ""}".toUri())
context.startActivity(intent)
}) {
Icon(Icons.Default.OpenInBrowser, null)
}
}
) }
) {
innerPadding ->
Column(modifier = Modifier
.fillMaxSize()
.padding(innerPadding)) {
if (showAppDescriptionSheet) {
ModalBottomSheet(
onDismissRequest = {showAppDescriptionSheet = false},
Modifier.systemBarsPadding()
) {
Column(
Modifier.verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("О приложении", fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text(state.item?.fullDescription ?: "")
}
}
}
if (state.isLoading) {
LinearProgressIndicator(modifier = Modifier
.padding(horizontal = 4.dp)
.fillMaxWidth())
} else if (state.error != null) {
Text("Ошибка: ${state.error}", modifier = Modifier.padding(16.dp))
} else {
val item = state.item
if (item != null) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (item.aggregatorInfo == null)
Color(0, 255, 0, 35)
else Color(255, 187, 0, 35)),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = {
val intent = Intent(Intent.ACTION_VIEW, "https://codeberg.org/mi6e4ka/openstore/src/branch/main/SAFETY.md".toUri())
context.startActivity(intent)
})
) {
Row(
modifier = Modifier
.padding(15.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Rounded.Security,
contentDescription = null,
modifier = Modifier.size(28.dp)
)
if (item.aggregatorInfo != null) {
Text("Предоставлено ${item.aggregatorInfo.source}")
} else {
Text("Предоставлено RuStore")
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(92.dp),
contentAlignment = Alignment.Center
) {
val animatedSize by animateDpAsState(
targetValue = if (state.isDownloading) 80.dp else 92.dp,
animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing),
label = "sizeAnimation"
)
val animatedCorner by animateDpAsState(
targetValue = if (state.isDownloading) 80.dp / 2 else 12.dp,
animationSpec = spring(dampingRatio = 0.6f),
label = "cornerAnimation"
)
if (state.installStatus !is InstallStatus.Pending) {
CircularProgressIndicator(
progress = { state.downloadProgress / 100f },
modifier = Modifier
.size(128.dp)
.alpha(if (state.isDownloading) 1f else 0f),
strokeWidth = 3.dp
)
} else {
CircularProgressIndicator(
modifier = Modifier
.size(128.dp)
.alpha(if (state.isDownloading) 1f else 0f),
strokeWidth = 3.dp
)
}
AppIcon(
item.iconUrl, modifier = Modifier
.size(animatedSize)
.clip(RoundedCornerShape(animatedCorner))
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(item.appName, fontSize = 24.sp, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(item.aggregatorInfo?.companyName ?: item.companyName, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
if (!state.isInstalled || state.isDownloading) {
//FilledTonalButton(onClick = {}, modifier = Modifier.weight(1f)) { Text("Copy apk link") }
Button(onClick = {
viewModel.downloadAndInstallApk(context)
}, modifier = Modifier.weight(1f), enabled = !state.isDownloading) {
if (state.installStatus is InstallStatus.Pending) {
Text("Установка...")
} else if (state.isDownloading) {
Text("Загрузка... ${state.downloadCurrentBytes/1024/1024}/${state.downloadTotalBytes/1024/1024}МБ")
} else {
Text("Установить")
}
}
} else {
FilledTonalButton(onClick = {
viewModel.deleteApplication(context, itemId)
}, modifier = Modifier.weight(1f)) { Text("Удалить") }
if (state.isNeedUpgrade) {
Button(onClick = {
viewModel.downloadAndInstallApk(context)
}, modifier = Modifier.weight(1f)) { Text("Обновить") }
} else {
Button(onClick = {
viewModel.runApplication(context, itemId)
}, modifier = Modifier.weight(1f)) { Text("Открыть") }
}
}
}
Row(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(bottom = 4.dp, top = 2.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
//InfoBadge("%.1f MB".format(item.fileSize/1024/1024), "Вес")
// InfoBadge(, "загрузок")
// InfoBadge("${getAndroidVersion(item.minSdkVersion)}+", "Android",
// Modifier
// .clip(RoundedCornerShape(12.dp))
// .clickable(onClick = {
//
// }))
// InfoBadge(item.ageLegal, "возраст")
// InfoBadge(item.versionName, "версия")
AssistChip(
onClick = {
Toast.makeText(context, "MinSdkVersion ${item.minSdkVersion}\nTargetSdkVersion ${item.targetSdkVersion}", Toast.LENGTH_SHORT).show() },
label = {Text("Android ${getAndroidVersion(item.minSdkVersion)} +")},
leadingIcon = {Icon(imageVector = Icons.Rounded.Android, contentDescription = null)}
)
AssistChip(
onClick = {},
label = {Text(item.roundedDownloadsText)},
leadingIcon = {Icon(imageVector = Icons.Rounded.Download, contentDescription = null)}
)
AssistChip(
onClick = {},
label = {Text(item.versionName)},
leadingIcon = {Icon(imageVector = Icons.Default.ArrowCircleUp, contentDescription = null)}
)
AssistChip(
onClick = {},
label = {Text(item.ageLegal)},
leadingIcon = {Icon(imageVector = Icons.Default.SupervisedUserCircle, contentDescription = null)}
)
}
GalleryWithPager(item.fileUrls)
}
var isWhatsNewExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {isWhatsNewExpanded = !isWhatsNewExpanded}
) {
Column(
Modifier.padding(16.dp)
.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
)
) {
Row {
Text("Что нового?", fontWeight = FontWeight.Bold, fontSize = 18.sp)
}
Text(item.whatsNew, maxLines = if (isWhatsNewExpanded) Int.MAX_VALUE else 4, overflow = TextOverflow.Ellipsis)
}
}
val interactionSource = remember { MutableInteractionSource() }
Column(
Modifier
.padding(bottom = 8.dp)
.clickable(interactionSource = interactionSource, indication = null) {
showAppDescriptionSheet = true
}
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("О приложении", fontWeight = FontWeight.Bold, fontSize = 18.sp)
IconButton(onClick = {showAppDescriptionSheet = true}) {
Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null)
}
}
Text(text = item.shortDescription)
}
Card(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = {navController.navigate("app/${item.packageName}/reviews"){restoreState=true} })
) {
Row(
Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Рейтинг и отзывы", fontWeight = FontWeight.Bold, fontSize = 18.sp)
// FilledTonalIconButton(onClick = {}) {
// Icon(imageVector = Icons.Rounded.ArrowForward, contentDescription = null)
// }
}
Row(modifier = Modifier.padding(start = 20.dp, end = 20.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("5", fontWeight = FontWeight.Bold)
LinearProgressIndicator(progress = {
((state.rating?.ratings?.amountFive ?: 0) / 100f)
}, modifier = Modifier.fillMaxWidth())
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("4", fontWeight = FontWeight.Bold)
LinearProgressIndicator(progress = {
((state.rating?.ratings?.amountFour ?: 0) / 100f)
}, modifier = Modifier.fillMaxWidth())
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("3", fontWeight = FontWeight.Bold)
LinearProgressIndicator(progress = {
((state.rating?.ratings?.amountThree ?: 0) / 100f)
}, modifier = Modifier.fillMaxWidth())
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("2", fontWeight = FontWeight.Bold)
LinearProgressIndicator(progress = {
((state.rating?.ratings?.amountTwo ?: 0) / 100f)
}, modifier = Modifier.fillMaxWidth())
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("1", fontWeight = FontWeight.Bold)
LinearProgressIndicator(progress = {
((state.rating?.ratings?.amountOne ?: 0) / 100f)
}, modifier = Modifier.fillMaxWidth())
}
}
Column(
modifier = Modifier.padding(start=24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("${state.rating?.averageUserRating ?: "?"}", fontWeight = FontWeight.Bold, fontSize = 24.sp)
Text("${state.rating?.totalRatings ?: "?"}", fontSize = 16.sp)
}
}
}
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(item.packageName, color = Color.Gray, fontSize = 14.sp)
}
//Spacer(Modifier.height(16.dp))
}
}
}
}
}
}
fun getAndroidVersion(apiLevel: Int): String {
val versions = mapOf(
1 to "1.0",
2 to "1.1",
3 to "1.5",
4 to "1.6",
5 to "2.0",
6 to "2.0.1",
7 to "2.1",
8 to "2.2",
9 to "2.3",
10 to "2.3.3",
11 to "3.0",
12 to "3.1",
13 to "3.2",
14 to "4.0",
15 to "4.0.3",
16 to "4.1",
17 to "4.2",
18 to "4.3",
19 to "4.4",
20 to "4.4W", // Wear
21 to "5.0",
22 to "5.1",
23 to "6.0",
24 to "7.0",
25 to "7.1",
26 to "8.0",
27 to "8.1",
28 to "9.0",
29 to "10",
30 to "11",
31 to "12",
32 to "12L", // Android 12L
33 to "13",
34 to "14",
35 to "15",
36 to "16",
37 to "17"
)
return versions[apiLevel] ?: "? (API $apiLevel)"
}

View file

@ -0,0 +1,180 @@
package dev.mi6e4ka.openstore.ui.screen.details
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dev.mi6e4ka.openstore.data.model.AppCommentsResponseBody
import dev.mi6e4ka.openstore.data.model.AppRatingResponseBody
import dev.mi6e4ka.openstore.data.model.ItemDetails
import dev.mi6e4ka.openstore.di.AppModule
import dev.mi6e4ka.openstore.internal.installer.ApkInstaller
import dev.mi6e4ka.openstore.internal.installer.PackageInstallerStatusReceiver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
data class DetailsState(
val isLoading: Boolean = false,
val item: ItemDetails? = null,
val rating: AppRatingResponseBody? = null,
val error: String? = null,
val downloadLink: String? = null,
val installStatus: InstallStatus = InstallStatus.Idle,
val isDownloading: Boolean = false,
val downloadProgress: Int = 0,
val downloadCurrentBytes: Long = 0,
val downloadTotalBytes: Long = 0,
val isInstalled: Boolean = false,
val isNeedUpgrade: Boolean = false,
)
sealed class InstallStatus {
object Idle : InstallStatus()
object Pending : InstallStatus()
object Success : InstallStatus()
}
class DetailsViewModel(private val itemId: String, private val apkInstaller: ApkInstaller) : ViewModel() {
private val _state = MutableStateFlow(DetailsState())
val state: StateFlow<DetailsState> = _state.asStateFlow()
fun loadDetails(context: Context, itemId: String) {
if (_state.value.item != null) {
return
}
Log.d(null, "Initial loading")
viewModelScope.launch {
try {
val item = AppModule.searchRepository.getItemDetails(itemId)
//
val packageInfo = getPackageInfo(context, itemId)
_state.value = DetailsState(isLoading = true, isInstalled = packageInfo != null)
if (packageInfo != null) {
if (packageInfo.versionCode < item.versionCode) {
_state.update { it.copy(isNeedUpgrade = true) }
}
}
//
val downloadLink = AppModule.searchRepository.getAppDownloadLink(item.appId)
val rating = AppModule.searchRepository.getAppRating(itemId)
_state.update{ it.copy(item = item, rating = rating, downloadLink = downloadLink, isLoading = false) }
Log.d("loading", "LOADED COMMENTS+RATING: ${state.value.rating}")
} catch (e: Exception) {
_state.update { it.copy(error = e.message, isLoading = false) }
}
}
}
fun downloadAndInstallApk(context: Context) {
viewModelScope.launch {
_state.update { it.copy(isDownloading = true) }
Log.d(null, "download apk " + state.value.downloadLink)
val file = apkInstaller.downloadApkWithProgress(state.value.downloadLink ?: "") { percent, downloaded, total ->
_state.update { it.copy(downloadProgress = percent, downloadCurrentBytes = downloaded, downloadTotalBytes = total) }
}
Log.d(null, "file: " + file.toUri())
try {
val apkFile = file
installApk(context, apkFile)
// val uri = FileProvider.getUriForFile(
// context,
// "${context.packageName}.provider",
// apkFile
// )
//
// val intent = Intent(Intent.ACTION_VIEW).apply {
// setDataAndType(uri, "application/vnd.android.package-archive")
// addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// }
// launcher.launch(intent)
} catch (e: Exception) {
Log.e(null, "ERROR: $e")
}
}
}
private fun installApk(context: Context, apkFile: File) {
viewModelScope.launch {
_state.update { it.copy(installStatus = InstallStatus.Pending) }
val installerCallback = object : android.content.pm.PackageInstaller.SessionCallback() {
override fun onCreated(sessionId: Int) {}
override fun onBadgingChanged(sessionId: Int) {}
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
override fun onProgressChanged(sessionId: Int, progress: Float) {}
override fun onFinished(sessionId: Int, success: Boolean) {
_state.update { it.copy(installStatus = InstallStatus.Success, isDownloading = false)}
if (success) {
_state.update { it.copy(isInstalled = true, isNeedUpgrade = false) }
apkInstaller.deleteApkFile(apkFile.name)
}
}
}
apkInstaller.installApk(context, apkFile, installerCallback)
}
}
private fun getPackageInfo(context: Context, packageName: String): PackageInfo? {
Log.d(null, "try to check package $packageName")
return try {
val packageInfo : PackageInfo
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageInfo = context.packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
packageInfo = context.packageManager.getPackageInfo(packageName, 0)
}
Log.d(null, "try")
packageInfo
} catch (e: PackageManager.NameNotFoundException) {
Log.d(null, "false")
null
} catch (e: Exception) {
null
}
}
fun runApplication(context: Context, packageName: String) {
val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent != null) {
context.startActivity(launchIntent)
} else {
Toast.makeText(context, "Не удалось открыть приложение", Toast.LENGTH_SHORT).show()
}
}
fun deleteApplication(context: Context, packageName: String) {
val intent = Intent(context, PackageInstallerStatusReceiver::class.java)
intent.putExtra(PackageInstallerStatusReceiver.ACTION_UNINSTALL, true)
val pendingIntent = PendingIntent.getBroadcast(context, -1, intent, PendingIntent.FLAG_MUTABLE)
context.packageManager.packageInstaller.uninstall(packageName, pendingIntent.intentSender)
}
fun updateInstallStatus(isInstalled: Boolean) {
_state.update { it.copy(isInstalled = isInstalled) }
}
}
class DetailsViewModelFactory(
private val itemId: String,
private val apkInstaller: ApkInstaller
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(DetailsViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return DetailsViewModel(itemId, apkInstaller) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View file

@ -0,0 +1,134 @@
package dev.mi6e4ka.openstore.ui.screen.results
import android.content.ClipData
import android.content.ClipboardManager
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.mi6e4ka.openstore.di.AppModule
import dev.mi6e4ka.openstore.ui.components.AppIcon
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResultsScreen(
query: String,
navController: NavController = rememberNavController(),
) {
//val viewModel = remember(query) { ResultsViewModel(query) }
val viewModel : ResultsViewModel = viewModel(
factory = ResultsViewModelFactory(query)
)
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
LaunchedEffect(query) {
viewModel.search()
}
Scaffold(
topBar = { TopAppBar(
title = {Text("Поиск")},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
) }
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
if (state.isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
)
} else if (state.error != null) {
Text("Ошибка: ${state.error}", modifier = Modifier.padding(16.dp))
} else {
LazyColumn(
modifier = Modifier.weight(1f).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(state.results) { item ->
Card(
modifier = Modifier
//.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = { navController.navigate("app/${item.packageName}") })
) {
Row(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(item.iconUrl, modifier = Modifier.size(60.dp).clip(RoundedCornerShape(16.dp)))
Column(
modifier = Modifier.weight(1f).padding(start = 16.dp,end = 4.dp)
) {
Text(text = item.appName, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = item.packageName, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = MaterialTheme.colorScheme.secondary)
}
// FilledTonalIconButton (
// onClick = {
// scope.launch {
// val clipboard = context.getSystemService(ClipboardManager::class.java)
// val link = AppModule.searchRepository.getAppDownloadLink(item.appId)
// val clip = ClipData.newPlainText("", link)
// clipboard.setPrimaryClip(clip)
// }
// }
// ) {
// Icon(
// imageVector = Icons.Filled.Link,
// contentDescription = "Copy app download link"
// )
// }
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,52 @@
package dev.mi6e4ka.openstore.ui.screen.results
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import dev.mi6e4ka.openstore.data.model.SearchResult
import dev.mi6e4ka.openstore.di.AppModule
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ResultsState(
val isLoading: Boolean = false,
val results: List<SearchResult> = emptyList(),
val error: String? = null
)
class ResultsViewModel(private val query: String) : ViewModel() {
private val _state = MutableStateFlow(ResultsState())
val state: StateFlow<ResultsState> = _state.asStateFlow()
fun search() {
if (_state.value.results.isNotEmpty()) {
return
}
_state.value = ResultsState(isLoading = true)
println("search...")
viewModelScope.launch {
try {
val results = AppModule.searchRepository.search(query)
_state.value = ResultsState(results = results)
} catch (e: Exception) {
_state.value = ResultsState(error = e.message)
println("Search error: ${e.message}")
}
}
}
}
class ResultsViewModelFactory(
private val query: String
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ResultsViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return ResultsViewModel(query) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View file

@ -0,0 +1,206 @@
package dev.mi6e4ka.openstore.ui.screen.reviews
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarRate
import androidx.compose.material.icons.filled.ThumbDown
import androidx.compose.material.icons.filled.ThumbDownOffAlt
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.ThumbUpAlt
import androidx.compose.material.icons.filled.ThumbUpOffAlt
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.outlined.StarRate
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.mi6e4ka.openstore.data.model.AppCommentsComments
import dev.mi6e4ka.openstore.di.AppModule
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Locale
enum class ReviewsFilters(val value: String, val displayName: String) {
NewFirst("NEW_FIRST", "Новые"),
UsefulFirst("USEFUL_FIRST", "Полезные"),
PositiveFirst("POSITIVE_FIRST", "Позитивные"),
NegativeFirst("NEGATIVE_FIRST", "Негативные")
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReviewsScreen(packageName: String, navController: NavController = rememberNavController()) {
var reviews: List<AppCommentsComments>? by remember { mutableStateOf(null) }
var selectedFilter by remember { mutableStateOf(ReviewsFilters.NewFirst) }
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(selectedFilter) {
isLoading = true
scope.launch {
reviews = try {
AppModule.searchRepository.getAppComments(packageName, 50, selectedFilter.value)
} catch (e: Exception) {
listOf(AppCommentsComments(
appRating = 1,
firstName = "Семен",
commentDate = "2025-08-30 10:25:20.000",
commentText = "Не работает этот ваш опенстор, удаляю",
likeCounter = 100,
dislikeCounter = 1,
updatedAt = "2025-08-30 10:25:20.000",
devResponse = "",
devResponseDate = "",
))
}
}
isLoading = false
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBar(
title = {Text("Отзывы")},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
) },
) { innerPadding ->
Column(
Modifier.padding(innerPadding).padding(horizontal = 16.dp)
) {
if (isLoading) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Row(
Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ReviewsFilters.entries.forEach { filter ->
FilterChip(
selected = filter == selectedFilter,
onClick = { selectedFilter = filter },
label = {Text(filter.displayName)},
leadingIcon = if (selectedFilter == filter) {
{ Icon(Icons.Default.Check, contentDescription = null) }
} else null
)
}
}
if ((reviews?.size ?: 0) == 0 && !isLoading) {
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Text("Отзывов пока нет")
}
}
}
items(reviews ?: listOf()) { review ->
Card(
Modifier.fillMaxWidth()
) {
Column(
Modifier.padding(16.dp)
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
) {
Text(review.firstName, fontWeight = FontWeight.Bold)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(formatTimestamp(review.commentDate), color = Color.Gray)
}
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
repeat(review.appRating) {
Icon(Icons.Filled.Star, null, Modifier.size(16.dp))
}
repeat(5 - review.appRating) {
Icon(Icons.Outlined.StarBorder, null, Modifier.size(16.dp))
}
}
Spacer(Modifier.height(8.dp))
Text(review.commentText)
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.ThumbUpOffAlt, contentDescription = null, Modifier.size(20.dp))
Spacer(Modifier.width(4.dp))
Text("${review.likeCounter}")
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.ThumbDownOffAlt, contentDescription = null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("${review.dislikeCounter}")
}
}
}
}
}
}
}
}
}
fun formatTimestamp(timestamp: String): String {
val inputFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
val outputFormat = SimpleDateFormat("d MMMM yyyy", Locale.getDefault())
val date = inputFormat.parse(timestamp)
return outputFormat.format(date ?: "")
}

View file

@ -0,0 +1,198 @@
package dev.mi6e4ka.openstore.ui.screen.search
import android.content.Intent
import android.content.pm.verify.domain.DomainVerificationManager
import android.content.pm.verify.domain.DomainVerificationUserState
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DomainVerification
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.mi6e4ka.openstore.BuildConfig
import dev.mi6e4ka.openstore.R
import java.net.URLEncoder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(navController: NavController = rememberNavController()) {
var search by remember { mutableStateOf("") }
var isDomainsVerified by remember { mutableStateOf(true) }
val context = LocalContext.current
OnResumeEffect {
isDomainsVerified = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val manager = context.getSystemService(DomainVerificationManager::class.java)
val states = manager.getDomainVerificationUserState(context.packageName)
states?.hostToStateMap?.forEach { state ->
Log.d("state", "$state")
if (state.value != 1) {
isDomainsVerified = false
}
}
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBar(title = {Text(stringResource(R.string.app_name))}) },
) { innerPadding ->
// if (showDialog) {
// AlertDialog(
// onDismissRequest = {showDialog=false},
// title = { Text("Добавьте ссылки RuStore") },
// text = { Text("Чтобы ") },
// confirmButton = {
// TextButton(onClick = {
// val intent = Intent(Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS)
// .apply { data = Uri.fromParts("package", context.packageName, null) }
// context.startActivity(intent)
// }) {
// Text("Подтвердить")
// }
// },
// dismissButton = {
// TextButton(onClick = {showDialog=false}) {
// Text("Позже")
// }
// }
// )
// }
Column(
modifier = Modifier
.padding(innerPadding)
.padding(horizontal = 15.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column {
if (!isDomainsVerified) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(0.dp)
.height(50.dp)
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = {
val intent = Intent(Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS)
.apply { data = Uri.fromParts("package", context.packageName, null) }
context.startActivity(intent)
}),
colors = CardDefaults.cardColors(containerColor = Color(255, 30, 30, 40))
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 10.dp)
) {
Icon(
imageVector = Icons.Default.DomainVerification,
contentDescription = null,
modifier = Modifier.size(32.dp),
)
Text("Домены не подтвержены", fontSize = 18.sp)
}
}
}
}
OutlinedTextField(
value = search,
onValueChange = {s -> search=s},
leadingIcon = { Icon(imageVector = Icons.Default.Search, contentDescription = null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = {
if (search.isNotBlank()) {
navController.navigate("search/${URLEncoder.encode(search, "UTF-8")}")
}
}
)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("source code", textDecoration = TextDecoration.Underline, modifier = Modifier.clickable(onClick = {
val intent = Intent(Intent.ACTION_VIEW, "https://codeberg.org/mi6e4ka/openstore".toUri())
context.startActivity(intent)
}))
Text("v${BuildConfig.VERSION_NAME}")
}
}
}
}
@Composable
fun OnResumeEffect(onResume: () -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnResume by rememberUpdatedState(onResume)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
currentOnResume()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}

View file

@ -1,11 +0,0 @@
package dev.mi6e4ka.openstore.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -1,6 +1,5 @@
package dev.mi6e4ka.openstore.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@ -9,50 +8,45 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
val md_theme_light_primary = Color(0xFF476810)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFC7F089)
// ..
// ..
val md_theme_dark_primary = Color(0xFFACD370)
val md_theme_dark_onPrimary = Color(0xFF213600)
val md_theme_dark_primaryContainer = Color(0xFF324F00)
// ..
// ..
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
// ..
)
private val DarkColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
// ..
)
@Composable
fun OpenStoreTheme(
fun Theme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val colors = when {
dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current)
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
MaterialTheme(colorScheme = colors, content = content)
}

View file

@ -1,34 +0,0 @@
package dev.mi6e4ka.openstore.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,160Q80,127 103.5,103.5Q127,80 160,80L480,80L720,320L720,490L640,490L640,360L440,360L440,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L600,800L600,880L160,880ZM160,800L160,490L160,490L160,360L160,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800ZM200,760Q204,711 230,670Q256,629 298,605L260,537Q260,536 264,522Q269,520 273.5,520Q278,520 280,525L319,595Q339,587 359,582.5Q379,578 400,578Q421,578 441,582.5Q461,587 481,595L520,525Q520,525 535,521Q540,523 541,528Q542,533 540,537L502,605Q544,629 570,670Q596,711 600,760L200,760ZM310,700Q318,700 324,694Q330,688 330,680Q330,672 324,666Q318,660 310,660Q302,660 296,666Q290,672 290,680Q290,688 296,694Q302,700 310,700ZM490,700Q498,700 504,694Q510,688 510,680Q510,672 504,666Q498,660 490,660Q482,660 476,666Q470,672 470,680Q470,688 476,694Q482,700 490,700ZM800,880L640,720L696,663L760,726L760,560L840,560L840,726L904,663L960,720L800,880Z"/>
</vector>

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenStore" parent="android:Theme.Material.Light.NoActionBar" />
<style name="Theme.OpenStore" parent="android:Theme.DeviceDefault.NoActionBar" />
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-cache-path name="cache" path="." />
<external-files-path name="files" path="." />
</paths>

View file

@ -1,17 +0,0 @@
package dev.mi6e4ka.openstore
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View file

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

View file

@ -1,19 +1,29 @@
[versions]
agp = "8.10.1"
coilCompose = "2.2.2"
converterGson = "2.9.0"
agp = "8.12.1"
coilCompose = "2.7.0"
converterGson = "3.0.0"
kotlin = "2.0.21"
coreKtx = "1.16.0"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
kotlinxCoroutinesAndroid = "1.10.2"
lifecycleRuntimeKtx = "2.9.1"
lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1"
composeBom = "2024.09.00"
lifecycleViewmodelKtx = "2.6.1"
navigationCompose = "2.9.0"
retrofit = "2.9.0"
composeBom = "2025.08.00"
lifecycleViewmodelKtx = "2.9.2"
navigationCompose = "2.9.3"
retrofit = "3.0.0"
loggingInterceptor = "5.1.0"
loggingInterceptorVersion = "4.11.0"
materialIconsExtendedAndroid = "1.7.8"
accompanistPager = "0.36.0"
accompanistPagerIndicators = "0.36.0"
ackpineCore = "0.15.3"
ackpineKtx = "0.15.2"
runtimeLivedata = "1.9.0"
material3 = "1.3.2"
accompanistNavigationAnimation = "0.36.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -31,12 +41,22 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version = "1.9.0" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" }
okhttp3-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version = "4.12.0" }
androidx-material-icons-extended-android = { group = "androidx.compose.material", name = "material-icons-extended-android", version.ref = "materialIconsExtendedAndroid" }
accompanist-pager = { group = "com.google.accompanist", name = "accompanist-pager", version.ref = "accompanistPager" }
accompanist-pager-indicators = { group = "com.google.accompanist", name = "accompanist-pager-indicators", version.ref = "accompanistPagerIndicators" }
ackpine-core = { group = "ru.solrudev.ackpine", name = "ackpine-core", version.ref = "ackpineCore" }
ackpine-ktx = { group = "ru.solrudev.ackpine", name = "ackpine-ktx", version.ref = "ackpineKtx" }
androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" }
material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
accompanist-navigation-animation = { group = "com.google.accompanist", name = "accompanist-navigation-animation", version.ref = "accompanistNavigationAnimation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View file

@ -1,6 +1,6 @@
#Tue Jun 24 22:14:23 MSK 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists