add chat basics

This commit is contained in:
2026-07-28 20:45:30 +03:00
parent 1f90bf67ab
commit 644bcf9895
72 changed files with 323 additions and 14 deletions
@@ -0,0 +1,2 @@
/build
/bin
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.convention.kmp)
alias(libs.plugins.convention.android.lib)
alias(libs.plugins.convention.serialization)
alias(libs.plugins.convention.koin)
alias(libs.plugins.convention.compose)
}
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":libs:ui"))
implementation(project(":libs:base"))
api(project(":feature:updater:updater-common"))
implementation(libs.ui.tooling.preview)
}
}
val androidMain by getting {
dependencies {
implementation(libs.androidx.core)
}
}
}
android { namespace = "ru.shadowsparky.updater" }
}
dependencies {
androidRuntimeClasspath(libs.ui.tooling)
}
@@ -0,0 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:ignore="RequestInstallPackagesPolicy" />
<application>
<receiver
android:name=".presentation.UpdateInstalledReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<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>
</application>
</manifest>
@@ -0,0 +1,140 @@
package ru.shadowsparky.updater.data
import android.app.Application
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInstaller
import java.io.File
import java.io.FileInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Factory
class AndroidUpdateInstaller(
private val app: Application
) : UpdateInstaller {
private val actionInstallStatus = "${app.packageName}.ACTION_INSTALL_STATUS"
override suspend fun hasInstallPermission(): Boolean =
app.packageManager.canRequestPackageInstalls()
override suspend fun requestInstallPermission() {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${app.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
app.startActivity(intent)
}
override suspend fun install(path: String) {
val file = File(path)
if (!file.exists()) {
error("APK file not found at: $path")
}
val packageInstaller = app.packageManager.packageInstaller
val sessionId = createSession(packageInstaller)
try {
writeApkToSession(packageInstaller, sessionId, file)
awaitInstallationResult(packageInstaller, sessionId)
} catch (e: Exception) {
runCatching { packageInstaller.abandonSession(sessionId) }
throw e
} finally {
file.delete()
}
}
private fun createSession(packageInstaller: PackageInstaller): Int {
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
return packageInstaller.createSession(params)
}
private fun writeApkToSession(packageInstaller: PackageInstaller, sessionId: Int, file: File) {
packageInstaller.openSession(sessionId).use { session ->
session.openWrite("update_stream", 0, file.length()).use { outputStream ->
FileInputStream(file).use { inputStream ->
val buffer = ByteArray(65536)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
}
session.fsync(outputStream)
}
}
}
}
private suspend fun awaitInstallationResult(packageInstaller: PackageInstaller, sessionId: Int) {
suspendCancellableCoroutine<Unit> { continuation ->
val receiver = createStatusReceiver(continuation)
registerStatusReceiver(receiver)
continuation.invokeOnCancellation {
runCatching { app.unregisterReceiver(receiver) }
runCatching { packageInstaller.abandonSession(sessionId) }
}
val intent = Intent(actionInstallStatus)
.setPackage(app.packageName)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
val pendingIntent = PendingIntent.getBroadcast(
app,
sessionId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
packageInstaller.openSession(sessionId).use { session ->
session.commit(pendingIntent.intentSender)
}
}
}
private fun createStatusReceiver(
continuation: CancellableContinuation<Unit>
): BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val userAction = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)
userAction?.let {
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(it)
}
}
PackageInstaller.STATUS_SUCCESS -> {
app.unregisterReceiver(this)
continuation.resume(Unit)
}
else -> {
app.unregisterReceiver(this)
continuation.resumeWithException(IllegalStateException("Installation failed ($status): $message"))
}
}
}
}
private fun registerStatusReceiver(receiver: BroadcastReceiver) {
ContextCompat.registerReceiver(
app,
receiver,
IntentFilter(actionInstallStatus),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
}
@@ -0,0 +1,78 @@
package ru.shadowsparky.updater.data
import android.app.Application
import io.ktor.client.HttpClient
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.updater.domain.DownloadProgress
import ru.shadowsparky.updater.domain.UpdateDownloader
import java.io.File
@Factory
class KtorUpdateDownloader(
private val httpClient: HttpClient,
app: Application,
private val dispatcherProvider: DispatcherProvider
) : UpdateDownloader {
private val destinationPath = "${app.cacheDir.absolutePath}/update"
override fun download(url: String): Flow<DownloadProgress> = flow {
try {
httpClient.prepareGet(url).execute { response ->
val contentLength = response.contentLength() ?: -1L
val channel: ByteReadChannel = response.bodyAsChannel()
val directory = File(destinationPath)
if (!directory.exists()) {
directory.mkdirs()
}
val fileName = "update.apk"
val fullPath = "$destinationPath/$fileName"
val file = File(fullPath)
if (file.exists()) file.delete()
val bufferSize = 8192
val buffer = ByteArray(bufferSize)
var downloadedBytes = 0L
file.outputStream().use { outputStream ->
while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read <= 0) break
outputStream.write(buffer, 0, read)
downloadedBytes += read
if (contentLength > 0) {
val progress = downloadedBytes.toFloat() / contentLength.toFloat()
emit(
DownloadProgress.Active(
progress = progress,
downloadedBytes = downloadedBytes,
totalBytes = contentLength
)
)
}
}
}
}
val finalFile = File("$destinationPath/update.apk")
emit(DownloadProgress.Success(path = finalFile.absolutePath))
} catch (e: Exception) {
emit(DownloadProgress.Error(e))
}
}.flowOn(dispatcherProvider.io)
override suspend fun clean(): Unit = withContext(dispatcherProvider.io) {
File(destinationPath).deleteRecursively()
}
}
@@ -0,0 +1,29 @@
package ru.shadowsparky.updater.presentation
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import ru.shadowsparky.koin
import ru.shadowsparky.updater.domain.UpdateDownloader
class UpdateInstalledReceiver : BroadcastReceiver() {
override fun onReceive(p0: Context?, p1: Intent?) {
if (p1?.action == Intent.ACTION_MY_PACKAGE_REPLACED) {
koin.getOrNull<UpdateDownloader>()?.let { downloader ->
val scope = CoroutineScope(Job() + Dispatchers.Main)
val async = goAsync()
scope.launch {
try {
downloader.clean()
} finally {
async.finish()
}
}
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="updater_isolated_cache" path="update/" />
</paths>
@@ -0,0 +1,20 @@
<resources>
<string name="updater_title_available">Доступно обновление на версию %1$s</string>
<string name="updater_title_not_found">Обновления не найдены</string>
<string name="updater_title_completed">Обновление успешно установлено</string>
<string name="updater_btn_close">Закрыть</string>
<string name="updater_btn_download">Скачать</string>
<string name="updater_changelog_title">Что нового:</string>
<string name="updater_changelog_empty">Улучшения производительности и исправления ошибок.</string>
<string name="updater_title_permission">Нужно разрешение</string>
<string name="updater_desc_permission">Для установки обновления приложению требуется разрешение на установку из неизвестных источников. Пожалуйста, включите его в настройках.</string>
<string name="updater_btn_grant">В настройки</string>
<string name="updater_title_downloading">Скачивание обновления…</string>
<string name="updater_download_progress">%1$s МБ из %2$s МБ</string>
<string name="updater_title_ready">Обновление готово к установке</string>
<string name="updater_btn_install">Установить</string>
<string name="updater_title_error">Ошибка обновления</string>
<string name="updater_btn_retry">Повторить</string>
</resources>
@@ -0,0 +1,10 @@
package ru.shadowsparky.updater
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
@Module
@ComponentScan
@Configuration
class UpdaterModule
@@ -0,0 +1,15 @@
package ru.shadowsparky.updater.domain
import kotlinx.coroutines.flow.Flow
interface UpdateDownloader {
fun download(url: String): Flow<DownloadProgress>
suspend fun clean()
}
sealed interface DownloadProgress {
data class Active(val progress: Float, val downloadedBytes: Long, val totalBytes: Long) : DownloadProgress
data class Success(val path: String) : DownloadProgress
data class Error(val throwable: Throwable) : DownloadProgress
}
@@ -0,0 +1,7 @@
package ru.shadowsparky.updater.domain
interface UpdateInstaller {
suspend fun hasInstallPermission(): Boolean
suspend fun requestInstallPermission()
suspend fun install(path: String)
}
@@ -0,0 +1,96 @@
package ru.shadowsparky.updater.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Factory
import ru.shadowsparky.domain.AppConfig
@Factory
class UpdateInteractor(
private val fetcher: UpdateFetcher?,
private val downloader: UpdateDownloader?,
private val installer: UpdateInstaller?,
private val appConfig: AppConfig?
) {
val hasSupport = fetcher != null && downloader != null && installer != null &&
appConfig?.versionCode != null
fun checkUpdate(): Flow<UpdateState> = flow {
if (appConfig?.versionCode == null || fetcher == null) {
emit(UpdateState.UpToDate)
return@flow
}
emit(UpdateState.Checking)
try {
val currentVersion = appConfig.versionCode ?: 0L
val updateInfo = fetcher.fetch(currentVersion)
val serverVersion = updateInfo.versionCode ?: 0L
if (serverVersion <= currentVersion) {
emit(UpdateState.UpToDate)
} else {
emit(
UpdateState.UpdateAvailable(
url = checkNotNull(updateInfo.url) { "url required" },
versionName = updateInfo.versionName ?: "v$serverVersion",
changeLog = updateInfo.changelog,
isForceUpdate = updateInfo.force
)
)
}
} catch (e: Exception) {
emit(UpdateState.Error(message = e.message ?: "Ошибка проверки", throwable = e))
}
}
fun startDownload(url: String): Flow<UpdateState> = flow {
if (installer == null || downloader == null) {
emit(UpdateState.UpToDate)
return@flow
}
if (!installer.hasInstallPermission()) {
emit(UpdateState.InstallPermissionRequired)
return@flow
}
emit(UpdateState.Downloading(0f, 0, 0))
downloader.download(url).firstOrNull { progress ->
when (progress) {
is DownloadProgress.Active -> {
emit(
UpdateState.Downloading(
progress = progress.progress,
downloadedBytes = progress.downloadedBytes,
totalBytes = progress.totalBytes
)
)
false
}
is DownloadProgress.Success -> {
emit(UpdateState.ReadyToInstall(filePath = progress.path))
true
}
is DownloadProgress.Error -> {
emit(UpdateState.Error(message = progress.throwable.message ?: "Ошибка скачивания", throwable = progress.throwable))
true
}
}
}
}
suspend fun requestInstallPermission() {
installer?.requestInstallPermission()
}
suspend fun installApk(path: String) {
installer?.install(path)
}
suspend fun reset() {
downloader?.clean()
}
suspend fun hasInstallPermission(): Boolean {
return installer?.hasInstallPermission() ?: false
}
}
@@ -0,0 +1,37 @@
package ru.shadowsparky.updater.domain
import androidx.compose.runtime.Immutable
@Immutable
sealed interface UpdateState {
data object Idle : UpdateState
data object Checking : UpdateState
data class UpdateAvailable(
val url: String,
val versionName: String,
val changeLog: String?,
val isForceUpdate: Boolean
) : UpdateState
data object InstallPermissionRequired : UpdateState
data object UpToDate : UpdateState
data object Completed : UpdateState
data class Downloading(
val progress: Float,
val downloadedBytes: Long,
val totalBytes: Long
) : UpdateState
data class ReadyToInstall(val filePath: String) : UpdateState
data class Error(
val message: String,
val throwable: Throwable? = null
) : UpdateState
}
@@ -0,0 +1,111 @@
package ru.shadowsparky.updater.presentation
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.MutableValue
import com.arkivanov.decompose.value.Value
import com.arkivanov.essenty.lifecycle.doOnDestroy
import com.arkivanov.essenty.lifecycle.doOnResume
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import ru.shadowsparky.updater.domain.UpdateInteractor
import ru.shadowsparky.updater.domain.UpdateState
class UpdateComponent(
componentContext: ComponentContext,
private val interactor: UpdateInteractor
) : ComponentContext by componentContext {
val hasSupport = interactor.hasSupport
var isForceUpdate = false
private set
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
_state.value = UpdateState.Error(throwable.message ?: "$throwable", throwable)
}
private var lastJob: Job? = null
private val scope = CoroutineScope(exceptionHandler + SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableValue<UpdateState>(UpdateState.Idle)
val state: Value<UpdateState> = _state
private var pendingUrl: String? = null
init {
lifecycle.doOnDestroy { scope.cancel() }
lifecycle.doOnResume {
val url = pendingUrl
if (_state.value is UpdateState.InstallPermissionRequired && url != null) {
scope.launch {
if (interactor.hasInstallPermission()) downloadUpdate(url)
}
}
}
}
fun checkUpdates(byUser: Boolean = true) {
if (!hasSupport) return
lastJob = scope.launch {
interactor.checkUpdate().collect {
isForceUpdate = (it as? UpdateState.UpdateAvailable)?.isForceUpdate ?: false
_state.value = if (byUser) {
it
} else {
when (it) {
UpdateState.Checking,
UpdateState.UpToDate,
is UpdateState.Error -> UpdateState.Idle
else -> it
}
}
}
}
}
fun downloadUpdate(url: String) {
pendingUrl = url
lastJob = scope.launch {
interactor.startDownload(url).collect { newState ->
_state.value = newState
if (newState !is UpdateState.InstallPermissionRequired) {
pendingUrl = null
}
if (newState is UpdateState.ReadyToInstall) {
installApk(newState.filePath)
}
}
}
}
fun requestPermission() {
scope.launch { interactor.requestInstallPermission() }
}
fun installApk(path: String) {
scope.launch {
_state.value = UpdateState.Checking
interactor.installApk(path)
_state.value = UpdateState.Completed
}
}
fun reset() {
scope.launch {
_state.value = UpdateState.Checking
if (lastJob?.isActive == true) lastJob?.cancel()
interactor.reset()
_state.value = UpdateState.Idle
}
}
}
@Factory
class UpdateComponentFactory(private val interactor: UpdateInteractor) {
fun create(componentContext: ComponentContext): UpdateComponent {
return UpdateComponent(componentContext, interactor)
}
}
@@ -0,0 +1,321 @@
package ru.shadowsparky.updater.presentation
import androiddev.libs.updater.updater_client.generated.resources.Res
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_close
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_download
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_grant
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_install
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_retry
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_empty
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_title
import androiddev.libs.updater.updater_client.generated.resources.updater_desc_permission
import androiddev.libs.updater.updater_client.generated.resources.updater_download_progress
import androiddev.libs.updater.updater_client.generated.resources.updater_title_available
import androiddev.libs.updater.updater_client.generated.resources.updater_title_completed
import androiddev.libs.updater.updater_client.generated.resources.updater_title_downloading
import androiddev.libs.updater.updater_client.generated.resources.updater_title_error
import androiddev.libs.updater.updater_client.generated.resources.updater_title_not_found
import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission
import androiddev.libs.updater.updater_client.generated.resources.updater_title_ready
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.updater.domain.UpdateState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateDialog(
component: UpdateComponent,
modifier: Modifier = Modifier
) {
val state by component.state.subscribeAsState()
val canDismiss = remember(state) { !component.isForceUpdate }
val isVisible = remember(state) { state !is UpdateState.Idle }
if (isVisible) {
AlertContent(
modifier = modifier,
onDismissRequest = { component.reset() },
canDismiss = canDismiss,
content = {
when (val currentState = state) {
is UpdateState.Checking -> UpdateCheckingContent()
is UpdateState.UpdateAvailable -> UpdateAvailableContent(
versionName = currentState.versionName,
changeLog = currentState.changeLog,
onDownloadClick = { component.downloadUpdate(currentState.url) }
)
is UpdateState.InstallPermissionRequired -> UpdatePermissionContent(
onGrantClick = { component.requestPermission() }
)
is UpdateState.Downloading -> UpdateDownloadingContent(
progress = currentState.progress,
downloadedBytes = currentState.downloadedBytes,
totalBytes = currentState.totalBytes
)
is UpdateState.ReadyToInstall -> UpdateReadyContent(
onInstallClick = { component.installApk(currentState.filePath) }
)
is UpdateState.Error -> UpdateErrorContent(
message = currentState.message,
onRetryClick = { component.checkUpdates() }
)
is UpdateState.UpToDate -> {
UpdateFinishedContent(onSkip = { component.reset() })
}
is UpdateState.Completed -> {
UpdateFinishedContent(
Res.string.updater_title_completed,
onSkip = { component.reset() }
)
}
else -> Unit
}
}
)
}
}
@Composable
private fun UpdateCheckingContent() {
Loading(Modifier.fillMaxWidth())
}
@Composable
private fun UpdateAvailableContent(
versionName: String,
changeLog: String?,
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_available, versionName),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
Text(
text = stringResource(Res.string.updater_changelog_title),
style = MaterialTheme.typography.titleMedium
)
}
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
Text(
text = changeLog ?: stringResource(Res.string.updater_changelog_empty),
style = MaterialTheme.typography.bodyMedium
)
}
item {
Button(onClick = onDownloadClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_download),
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
}
@Composable
private fun UpdatePermissionContent(
onGrantClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_permission),
style = MaterialTheme.typography.titleLarge
)
Text(
text = stringResource(Res.string.updater_desc_permission),
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = onGrantClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_grant),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
private fun UpdateDownloadingContent(
progress: Float,
downloadedBytes: Long,
totalBytes: Long,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_downloading),
style = MaterialTheme.typography.titleMedium
)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
val downloadedMb = ((downloadedBytes / (1024f * 1024f)) * 10).toInt() / 10.0
val totalMb = ((totalBytes / (1024f * 1024f)) * 10).toInt() / 10.0
Text(
text = stringResource(
Res.string.updater_download_progress,
downloadedMb.toString(),
totalMb.toString()
),
style = MaterialTheme.typography.bodySmall
)
}
}
@Composable
private fun UpdateReadyContent(
onInstallClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_ready),
style = MaterialTheme.typography.titleLarge
)
Button(onClick = onInstallClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_install),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
private fun UpdateFinishedContent(
text: StringResource = Res.string.updater_title_not_found,
onSkip: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(text), style = MaterialTheme.typography.titleLarge)
Button(onClick = onSkip, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_close),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
private fun UpdateErrorContent(
message: String,
onRetryClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(Res.string.updater_title_error),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.error
)
Text(text = message, style = MaterialTheme.typography.bodyMedium)
Button(onClick = onRetryClick, modifier = Modifier.fillMaxWidth()) {
Text(
stringResource(Res.string.updater_btn_retry),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Preview
@Composable
private fun UpdateAvailablePreview() {
MaterialTheme {
UpdateAvailableContent(
versionName = "v1.4.0",
changeLog = "- Добавили темную тему\n- Исправили падение на главном экране\n- Повысили стабильность",
onDownloadClick = {}
)
}
}
@Preview
@Composable
private fun UpdateDownloadingPreview() {
MaterialTheme {
UpdateDownloadingContent(
progress = 0.45f,
downloadedBytes = 24_117_248L, // ~23 MB
totalBytes = 52_428_800L // 50 MB
)
}
}
@Preview
@Composable
private fun UpdatePermissionPreview() {
MaterialTheme {
UpdatePermissionContent(onGrantClick = {})
}
}
@Preview
@Composable
private fun UpdateErrorPreview() {
MaterialTheme {
UpdateErrorContent(message = "Http status 503: Service Unavailable", onRetryClick = {})
}
}