add AlertContent, add RouteNavigator

This commit is contained in:
2026-07-26 20:25:05 +03:00
parent 38a93ed95e
commit 30b1acd3ff
39 changed files with 398 additions and 395 deletions
+5
View File
@@ -12,8 +12,13 @@ kotlin {
dependencies {
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.ui.tooling.preview)
}
}
}
android { namespace = "ru.shadowsparky.ui" }
}
dependencies {
androidRuntimeClasspath(libs.ui.tooling)
}
@@ -0,0 +1,69 @@
package ru.shadowsparky.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlertContent(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
canDismiss: Boolean = true,
content: @Composable () -> Unit,
) {
@Composable
fun BaseDialogLayout() {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
content()
}
}
if (isTv()) {
BasicAlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = DialogProperties(
dismissOnBackPress = canDismiss,
dismissOnClickOutside = canDismiss
)
) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp
) { BaseDialogLayout() }
}
} else {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = ModalBottomSheetProperties(
shouldDismissOnBackPress = canDismiss,
shouldDismissOnClickOutside = canDismiss
),
shape = MaterialTheme.shapes.extraLarge
) { BaseDialogLayout() }
}
}
@@ -8,6 +8,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.painterResource
@Composable
@@ -22,3 +23,9 @@ fun BackButton(doBack: () -> Unit) {
)
) { Icon(painterResource(Res.drawable.back), contentDescription = null) }
}
@Preview
@Composable
private fun BackButtonPreview() {
BackButton {}
}
@@ -11,6 +11,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -139,3 +140,23 @@ fun ExpressiveCard(
modifier = modifier.padding(horizontal = horizontalPadding, vertical = verticalSpacing)
) { content() }
}
@Composable
@Preview
private fun ExpressiveLazyColumnPreview() {
ExpressiveLazyColumn {
section {
item {
TextPreference(text = "item one")
}
item {
TextPreference(text = "item two")
}
}
section {
item {
TextPreference(text = "next section started")
}
}
}
}
@@ -1,66 +0,0 @@
package ru.shadowsparky.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> ListPreference(
title: String,
items: Map<T, String>,
selectedItem: T,
onItemSelected: (T) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val entries = remember(items) { items.entries.toList() }
val selectedLabel = remember(items, selectedItem) { items[selectedItem].orEmpty() }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { if (enabled) expanded = !expanded },
modifier = modifier
) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {}, // readOnly
readOnly = true,
label = { Text(title) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled)
.fillMaxWidth(),
enabled = enabled,
singleLine = true
)
ExposedDropdownMenu(
expanded = expanded && enabled,
onDismissRequest = { expanded = false }
) {
entries.forEach { (value, label) ->
DropdownMenuItem(
text = { Text(label) },
onClick = {
expanded = false
if (value != selectedItem) onItemSelected(value)
}
)
}
}
}
}
@@ -7,6 +7,7 @@ import androidx.compose.material3.LoadingIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -16,3 +17,9 @@ fun Loading(modifier: Modifier) {
LoadingIndicator(Modifier.size(96.dp))
}
}
@Preview
@Composable
fun LoadingPreview() {
Loading(Modifier)
}
@@ -60,4 +60,3 @@ fun PasswordTextField(
}
)
}
@@ -1,27 +0,0 @@
package ru.shadowsparky.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PreferenceCategory(
title: String,
modifier: Modifier = Modifier,
) {
Text(
text = title,
modifier = modifier
.padding(
start = 16.dp,
top = 24.dp,
end = 16.dp,
bottom = 8.dp,
),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
}
@@ -3,5 +3,5 @@ package ru.shadowsparky.ui.nav
import com.arkivanov.decompose.ComponentContext
interface ComponentFactory<T : Child, R : Route, BaseRoute : Route> {
fun create(route: R, child: ComponentContext, root: BaseRootComponent<BaseRoute>): T
fun create(route: R, child: ComponentContext, root: RouteNavigator<BaseRoute>): T
}
@@ -14,7 +14,7 @@ import ru.shadowsparky.ui.theme.getColorScheme
@OptIn(ExperimentalDecomposeApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun Nav(
rootComponent: BaseRootComponent<*>,
rootComponent: RootComponent<*>,
process: @Composable (child: Child, route: Route) -> Unit
) {
Children(
@@ -18,18 +18,18 @@ import kotlinx.serialization.KSerializer
import org.koin.core.Koin
import org.koin.core.qualifier.named
abstract class BaseRootComponent<R : Route>(
abstract class RootComponent<R : Route>(
private val componentContext: ComponentContext,
) : ComponentContext by componentContext, BackHandlerOwner {
) : ComponentContext by componentContext, BackHandlerOwner, RouteNavigator<R> {
protected abstract val initStack: List<R>
protected abstract val serializer: KSerializer<R>
protected abstract val koin: Koin
private val scope = coroutineScope()
protected val nav = StackNavigation<R>()
protected val childStack by lazy {
val childStack by lazy {
childStack(
source = nav,
serializer = serializer,
@@ -41,36 +41,26 @@ abstract class BaseRootComponent<R : Route>(
private val _onFinish = MutableStateFlow<Unit?>(null)
val onFinish = _onFinish.filterNotNull()
fun navReplace(vararg route: R) {
override fun navReplace(vararg route: R) {
nav.replaceAll(*route)
}
fun nav(route: R, bringToFront: Boolean = false) {
if (bringToFront) {
nav.bringToFront(route)
} else {
nav.pushNew(route)
}
override fun nav(route: R, bringToFront: Boolean) {
if (bringToFront) nav.bringToFront(route) else nav.pushNew(route)
}
fun onBack() {
nav.pop { popped ->
if (!popped) finish()
}
override fun onBack() {
nav.pop { popped -> if (!popped) finish() }
}
fun finish() {
override fun finish() {
scope.launch { _onFinish.emit(Unit) }
}
val valueChildStack: Value<ChildStack<*, Child>> by lazy { childStack }
protected open fun newChild(route: Route, ctx: ComponentContext): Child {
val factory = route.provideFactory<Child, Route>(koin)
return factory.create(route, ctx, this as BaseRootComponent<Route>)
}
private fun <T : Child, R : Route> Route.provideFactory(koin: Koin): ComponentFactory<T, R, Route> {
return koin.get<ComponentFactory<T, R, Route>>(named(routeId))
val factory = koin.get<ComponentFactory<Child, Route, R>>(named(route.routeId))
return factory.create(route, ctx, this as RouteNavigator<R>)
}
}
@@ -0,0 +1,8 @@
package ru.shadowsparky.ui.nav
interface RouteNavigator<out R : Route> {
fun navReplace(vararg route: @UnsafeVariance R)
fun nav(route: @UnsafeVariance R, bringToFront: Boolean = false)
fun onBack()
fun finish()
}
@@ -18,18 +18,14 @@ import androiddev.libs.updater.updater_client.generated.resources.updater_title_
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.Box
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.foundation.layout.padding
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -38,10 +34,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
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.Loading
import ru.shadowsparky.updater.domain.UpdateState
@@ -55,50 +51,50 @@ fun UpdateDialog(
val canDismiss = remember(state) { !component.isForceUpdate }
val isVisible = remember(state) { state !is UpdateState.Idle }
if (isVisible) {
BasicAlertDialog(
AlertContent(
modifier = modifier,
onDismissRequest = { component.reset() },
properties = DialogProperties(
canDismiss,
canDismiss,
),
canDismiss = canDismiss,
content = {
Surface(shape = MaterialTheme.shapes.extraLarge) {
Box(modifier = Modifier.padding(16.dp)) {
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
}
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
}
}
)
@@ -138,7 +134,10 @@ private fun UpdateAvailableContent(
)
}
Button(onClick = onDownloadClick, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.updater_btn_download), style = MaterialTheme.typography.labelLarge)
Text(
stringResource(Res.string.updater_btn_download),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@@ -153,10 +152,19 @@ private fun UpdatePermissionContent(
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)
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)
Text(
stringResource(Res.string.updater_btn_grant),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@@ -173,14 +181,21 @@ private fun UpdateDownloadingContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(Res.string.updater_title_downloading), style = MaterialTheme.typography.titleMedium)
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()),
text = stringResource(
Res.string.updater_download_progress,
downloadedMb.toString(),
totalMb.toString()
),
style = MaterialTheme.typography.bodySmall
)
}
@@ -196,9 +211,15 @@ private fun UpdateReadyContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = stringResource(Res.string.updater_title_ready), style = MaterialTheme.typography.titleLarge)
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)
Text(
stringResource(Res.string.updater_btn_install),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@@ -216,7 +237,10 @@ private fun UpdateFinishedContent(
) {
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)
Text(
stringResource(Res.string.updater_btn_close),
style = MaterialTheme.typography.labelLarge
)
}
}
}
@@ -232,10 +256,17 @@ private fun UpdateErrorContent(
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 = 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)
Text(
stringResource(Res.string.updater_btn_retry),
style = MaterialTheme.typography.labelLarge
)
}
}
}