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
@@ -16,14 +16,14 @@ import kotlinx.coroutines.launch
import ru.shadowsparky.koin
import ru.shadowsparky.ui.theme.LocalColorSchemeProvider
import ru.shadowsparky.vbox.shared.presentation.Nav
import ru.shadowsparky.vbox.shared.presentation.RootComponent
import ru.shadowsparky.vbox.shared.presentation.RootFactory
import ru.shadowsparky.vbox.shared.presentation.UrlRoutes
import ru.shadowsparky.vbox.shared.presentation.VBoxRootComponent
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.videobox.multiplatform.piggy.getPiggyColorScheme
class MainActivity : ComponentActivity() {
private lateinit var root: RootComponent
private lateinit var root: VBoxRootComponent
override fun onCreate(savedInstanceState: Bundle?) {
edgeToEdge(false)
@@ -46,10 +46,10 @@ class MainActivity : ComponentActivity() {
setContent {
if (isPiggyActivity) {
CompositionLocalProvider(LocalColorSchemeProvider provides getPiggyColorScheme()) {
Nav(rootComponent = root)
Nav(root = root)
}
} else {
Nav(rootComponent = root)
Nav(root = root)
}
edgeToEdge(isSystemInDarkTheme())
}
@@ -42,15 +42,15 @@ fun Modifier.imePaddingInternal(): Modifier {
}
@Composable
fun Nav(rootComponent: RootComponent) {
fun Nav(root: VBoxRootComponent) {
CompositionLocalProvider(
LocalImageLoader provides remember { rootComponent.imageLoaderProvider.provide() }
) { NavInternal(rootComponent) }
LocalImageLoader provides remember { root.imageLoaderProvider.provide() }
) { NavInternal(root) }
}
@Composable
private fun NavInternal(rootComponent: RootComponent) {
Nav(rootComponent) { child, route ->
private fun NavInternal(root: VBoxRootComponent) {
Nav(root) { child, route ->
when (child) {
is Child.PlayerChild -> PlayerScreen(child.component)
is Child.OverviewChild -> OverviewScreen(child.component)
@@ -73,12 +73,12 @@ private fun NavInternal(rootComponent: RootComponent) {
MainScreen(
route as Route,
child,
rootComponent
root
)
}
}
}
UpdateDialog(rootComponent.updateComponent)
UpdateDialog(root.updateComponent)
}
}
@@ -22,15 +22,15 @@ import ru.shadowsparky.vbox.shared.presentation.search.SearchScreen
import ru.shadowsparky.vbox.shared.presentation.settings.SettingsContent
@Composable
fun MainScreen(route: Route, child: Child, rootComponent: RootComponent) {
fun MainScreen(route: Route, child: Child, root: VBoxRootComponent) {
when (child) {
is Child.SearchChild -> SearchScreen(child.component, route) { rootComponent.nav(it, true) }
is Child.SearchChild -> SearchScreen(child.component, route) { root.nav(it, true) }
is Child.SavedMovieChild -> SavedMovieScreen(
child.component,
route
) { rootComponent.navReplace(it) }
) { root.navReplace(it) }
else -> MainScreenInternal(route, child) { rootComponent.navReplace(it) }
else -> MainScreenInternal(route, child) { root.navReplace(it) }
}
}
@@ -12,23 +12,23 @@ import kotlinx.serialization.KSerializer
import org.koin.core.Koin
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.updater.presentation.UpdateComponentFactory
import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider
import ru.shadowsparky.vbox.shared.domain.HeathCheck
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalDecomposeApi::class)
class RootComponent(
class VBoxRootComponent(
componentContext: ComponentContext,
tokenStorage: TokenStorage,
override val initStack: List<Route>,
private val healthCheck: HeathCheck,
val imageLoaderProvider: ImageLoaderProvider,
private val updateComponentFactory: UpdateComponentFactory,
updateComponentFactory: UpdateComponentFactory,
override val serializer: KSerializer<Route> = Route.serializer(),
override val koin: Koin = ru.shadowsparky.koin
) : BaseRootComponent<Route>(componentContext), WebNavigationOwner {
) : RootComponent<Route>(componentContext), WebNavigationOwner {
val updateComponent = updateComponentFactory.create(childContext("UpdateComponentRoot"))
private val scope = createComponentScope()
val isLoggedIn = tokenStorage.token.map {
@@ -68,15 +68,15 @@ class RootFactory(
fun create(
context: ComponentContext,
initStack: Route
): RootComponent {
): VBoxRootComponent {
return create(context, listOf(initStack))
}
fun create(
context: ComponentContext,
initStack: List<Route> = listOf(Route.default)
): RootComponent {
return RootComponent(
): VBoxRootComponent {
return VBoxRootComponent(
context,
tokenStorage,
listOf(Route.Loading(next = initStack.toSet())),
@@ -4,6 +4,7 @@ import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.not_found
import androiddev.apps.vbox.client.shared.generated.resources.recommendations
import androiddev.apps.vbox.client.shared.generated.resources.retry
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -80,7 +81,6 @@ fun VideoScreen(component: VideoScreenComponent) {
onRefresh = { component.load() },
onShowOverview = { component.showOverview(it) },
emptyResource = Res.string.not_found,
onLoad = { component.load() }
)
},
appBar = {
@@ -107,8 +107,7 @@ fun VideoContent(component: VideoScreenComponent, paddingValues: PaddingValues)
items,
onRefresh = { component.load() },
onShowOverview = { component.showOverview(it) },
emptyResource = Res.string.not_found,
onLoad = { component.load() }
emptyResource = Res.string.not_found
)
}
@@ -119,20 +118,21 @@ fun VideoScreenContent(
items: List<VideoItem>?,
emptyResource: StringResource,
onRefresh: () -> Unit,
onShowOverview: (Long) -> Unit,
onLoad: () -> Unit
onShowOverview: (Long) -> Unit
) {
LaunchedEffect(Unit) {
onLoad()
}
if (error != null) {
ErrorAndRetry(error) {
onRefresh()
}
} else if (items != null) {
VideosInfo(items = items, paddingValues, emptyResource) {
onShowOverview(it.id)
}
AnimatedContent(
items,
content = {
VideosInfo(items = items, paddingValues, emptyResource) {
onShowOverview(it.id)
}
}
)
} else {
Loading()
}
@@ -9,8 +9,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
@@ -39,6 +39,8 @@ class VideoScreenComponent(
val items: StateFlow<List<VideoItem>?> = _items.asStateFlow()
val errorCode = _errorMsg.asSharedFlow()
init { load() }
fun load() {
componentScope.launch(dispatcherProvider.main + exceptionHandler) {
_errorMsg.emit(null)
@@ -58,7 +60,7 @@ class VideosChildFactory(
override fun create(
route: Route.Videos,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.VideosChild {
val comp = VideoScreenComponent(
child,
@@ -6,8 +6,8 @@ import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
@@ -26,7 +26,7 @@ class LoadingComponentFactory(
override fun create(
route: Route.Loading,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.LoadingChild {
return Child.LoadingChild(
LoadingComponent(
@@ -7,7 +7,7 @@ import ru.shadowsparky.vbox.shared.presentation.overview.OverviewComponent
import ru.shadowsparky.vbox.shared.presentation.overview.episode.EpisodeOverviewComponent
import ru.shadowsparky.vbox.shared.presentation.overview.season.SeasonOverviewComponent
import ru.shadowsparky.vbox.shared.presentation.player.PlayerComponent
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieScreenComponent
import ru.shadowsparky.vbox.shared.presentation.saved.SavedMovieComponent
import ru.shadowsparky.vbox.shared.presentation.search.SearchScreenComponent
import ru.shadowsparky.vbox.shared.presentation.settings.SettingsComponent
import ru.shadowsparky.vbox.shared.presentation.settings.auth.AuthComponent
@@ -27,7 +27,7 @@ sealed class Child : ru.shadowsparky.ui.nav.Child {
class EditServerAddrChild(val component: EditServComponent) : Child()
class AuthChild(val component: AuthComponent) : Child()
class RegisterChild(val component: RegisterComponent) : Child()
class SavedMovieChild(val component: SavedMovieScreenComponent) : Child()
class SavedMovieChild(val component: SavedMovieComponent) : Child()
class EditPasswordChild(val component: EditPasswordComponent) : Child()
class LoadingChild(val component: LoadingComponent) : Child()
class PlayerChild(val component: PlayerComponent) : Child()
@@ -23,7 +23,7 @@ sealed class Route : ru.shadowsparky.ui.nav.Route() {
@Serializable
data class Loading(
val authRequired: Boolean = isJs,
val authRequired: Boolean = true,
val next: Collection<Route> = listOf(default),
override val routeId: String = RouteIds.LOADING
) : Route()
@@ -4,8 +4,8 @@ import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.OfflineState
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
import ru.shadowsparky.vbox.shared.domain.OfflineVideoInteractor
@@ -50,7 +50,7 @@ class OfflineVideoComponentFactory(
override fun create(
route: Route.OfflineVideo,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.OfflineVideoChild {
val comp = OfflineVideoComponent(
child,
@@ -15,8 +15,9 @@ import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.Clip
import ru.shadowsparky.domain.ClipboardManager
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.AddOfflineVideoUseCase
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
@@ -165,7 +166,7 @@ class OverviewFactory(
override fun create(
route: Route.Overview,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.OverviewChild {
val comp = OverviewComponent(
savedMovieRepository,
@@ -20,8 +20,9 @@ import org.koin.core.annotation.Named
import ru.shadowsparky.domain.Clip
import ru.shadowsparky.domain.ClipboardManager
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.AddOfflineVideoUseCase
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
import ru.shadowsparky.vbox.shared.domain.OfflineVideo
@@ -178,7 +179,7 @@ class EpisodeOverviewFactory(
override fun create(
route: Route.Overview.Episode,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.EpisodeOverviewChild {
val comp = EpisodeOverviewComponent(
child,
@@ -8,8 +8,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.VideoApi
import ru.shadowsparky.vbox.shared.domain.model.Season
@@ -54,7 +54,7 @@ class SeasonOverviewFactory(
override fun create(
route: Route.Overview.Season,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.SeasonOverviewChild {
val comp = SeasonOverviewComponent(
videoApi,
@@ -9,8 +9,9 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.PlayerInteractor
import ru.shadowsparky.vbox.shared.presentation.createComponentScope
import ru.shadowsparky.vbox.shared.presentation.nav.Child
@@ -112,7 +113,7 @@ class PlayerComponentFactory(
override fun create(
route: Route.Overview.Player,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.PlayerChild {
val component = PlayerComponent(
child,
@@ -8,8 +8,9 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
import ru.shadowsparky.vbox.shared.domain.model.VideoItem
@@ -19,7 +20,7 @@ import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
class SavedMovieScreenComponent(
class SavedMovieComponent(
private val context: ComponentContext,
repo: SavedMovieRepository,
userTagRepository: UserTagRepository,
@@ -70,9 +71,9 @@ class SavedMovieFactory(
override fun create(
route: Route.Bookmark,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.SavedMovieChild {
val comp = SavedMovieScreenComponent(
val comp = SavedMovieComponent(
child,
repo,
doBack = { root.onBack() },
@@ -7,21 +7,16 @@ import androiddev.apps.vbox.client.shared.generated.resources.saved_movies_not_f
import androiddev.apps.vbox.client.shared.generated.resources.user_tags
import androiddev.apps.vbox.client.shared.generated.resources.user_tags_filter_disable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.foundation.lazy.items
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -38,6 +33,7 @@ import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.LocalBlurState
import ru.shadowsparky.ui.applyBlurEffect
import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
@@ -47,7 +43,7 @@ import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SavedMovieScreen(comp: SavedMovieScreenComponent, route: Route, onNavigate: (Route) -> Unit) {
fun SavedMovieScreen(comp: SavedMovieComponent, route: Route, onNavigate: (Route) -> Unit) {
val tags by comp.userTags.collectAsStateWithLifecycle(null)
var filterBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
BaseMainScreen(
@@ -86,7 +82,7 @@ fun SavedMovieScreen(comp: SavedMovieScreenComponent, route: Route, onNavigate:
}
@Composable
private fun SavedMovieContent(comp: SavedMovieScreenComponent, paddingValues: PaddingValues) {
private fun SavedMovieContent(comp: SavedMovieComponent, paddingValues: PaddingValues) {
val items by comp.all.collectAsStateWithLifecycle(null)
VideoScreenContent(
paddingValues,
@@ -94,8 +90,7 @@ private fun SavedMovieContent(comp: SavedMovieScreenComponent, paddingValues: Pa
items,
onRefresh = {},
onShowOverview = { comp.showOverview(it) },
emptyResource = Res.string.saved_movies_not_found,
onLoad = {}
emptyResource = Res.string.saved_movies_not_found
)
}
@@ -108,38 +103,30 @@ private fun FilterDialog(
onFilterBy: (String?) -> Unit
) {
allTags ?: return
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(shape = MaterialTheme.shapes.extraLarge) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(Res.string.user_tags),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(16.dp))
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
FilterItemCheckBox(
isChecked = filterTag == null,
name = stringResource(Res.string.user_tags_filter_disable)
) {
onFilterBy(null)
onDismiss()
}
}
items(
items = allTags.filter { it.id != null },
key = { tag -> tag.id ?: tag.tag }
) { tag ->
Spacer(Modifier.height(4.dp))
FilterItemCheckBox(
isChecked = filterTag == tag.tag,
name = tag.tag
) {
onFilterBy(tag.tag)
onDismiss()
}
}
AlertContent(
onDismiss,
title = stringResource(Res.string.user_tags)
) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
FilterItemCheckBox(
isChecked = filterTag == null,
name = stringResource(Res.string.user_tags_filter_disable)
) {
onFilterBy(null)
onDismiss()
}
}
items(
items = allTags.filter { it.id != null },
key = { tag -> tag.id ?: tag.tag }
) { tag ->
FilterItemCheckBox(
isChecked = filterTag == tag.tag,
name = tag.tag
) {
onFilterBy(tag.tag)
onDismiss()
}
}
}
@@ -4,8 +4,9 @@ import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.SearchRepository
import ru.shadowsparky.vbox.shared.presentation.createComponentScope
import ru.shadowsparky.vbox.shared.presentation.nav.Child
@@ -43,7 +44,7 @@ class SearchScreenFactory(
override fun create(
route: Route.SearchVideos,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.SearchChild {
val comp = SearchScreenComponent(
child,
@@ -9,8 +9,8 @@ import org.koin.core.annotation.Named
import ru.shadowsparky.domain.AppConfig
import ru.shadowsparky.http.domain.LogoutAction
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.updater.presentation.UpdateComponentFactory
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.OfflineStorage
@@ -83,7 +83,7 @@ class SettingsComponentFactory(
override fun create(
route: Route.Settings,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.SettingsChild {
val comp = SettingsComponent(
child,
@@ -7,8 +7,8 @@ import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.domain.Log
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.model.LoginException
@@ -64,7 +64,7 @@ class AuthComponentFactory(
override fun create(
route: Route.Settings.Auth,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.AuthChild {
val comp = AuthComponent(
route.required,
@@ -6,8 +6,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
import ru.shadowsparky.vbox.shared.domain.model.LoginException
@@ -53,7 +54,7 @@ class EditPasswordComponentFactory(
override fun create(
route: Route.Settings.EditPassword,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.EditPasswordChild {
val component = EditPasswordComponent(
child,
@@ -8,8 +8,8 @@ import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import org.koin.core.component.KoinComponent
import ru.shadowsparky.domain.Log
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.model.LoginException
@@ -55,7 +55,7 @@ class RegisterComponentFactory(
override fun create(
route: Route.Settings.Register,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.RegisterChild {
val comp = RegisterComponent(
child,
@@ -9,8 +9,9 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.di.factory.DispatcherProvider
import ru.shadowsparky.vbox.shared.domain.ServerConfiguration
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
@@ -27,7 +28,6 @@ class EditServComponent(
) : ComponentContext by context {
private val componentScope = createComponentScope()
val serverUrl = serverConfigurationRepository.serverConfiguration
.filterNotNull()
.map { it.asHttpStr() }
.onEach { onUrlChanged(it) }
private val _state = MutableStateFlow<EditServState>(EditServState.Idle)
@@ -67,7 +67,7 @@ class EditServComponentFactory(
override fun create(
route: Route.Settings.EditServ,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.EditServerAddrChild {
val comp = EditServComponent(
child,
@@ -4,8 +4,6 @@ import androiddev.apps.vbox.client.shared.generated.resources.Res
import androiddev.apps.vbox.client.shared.generated.resources.movie_tags_empty
import androiddev.apps.vbox.client.shared.generated.resources.user_tags
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -14,15 +12,14 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.vbox.shared.domain.tag.TagInfo
@@ -36,34 +33,25 @@ fun LinkTagBottomSheet(
requestTagsScreen: () -> Unit,
updateTag: (String, Boolean) -> Unit
) {
ModalBottomSheet(
AlertContent(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(true)
title = stringResource(Res.string.user_tags)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(Res.string.user_tags),
style = MaterialTheme.typography.titleLarge
)
if (allTags == null || linkedTags == null) {
Loading(Modifier.fillMaxWidth())
} else {
if (allTags.isEmpty()) {
Button(
onClick = {
onDismiss()
requestTagsScreen()
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(Res.string.movie_tags_empty))
}
} else {
ShowTags(allTags, linkedTags, updateTag)
if (allTags == null || linkedTags == null) {
Loading(Modifier.fillMaxWidth())
} else {
if (allTags.isEmpty()) {
Button(
onClick = {
onDismiss()
requestTagsScreen()
},
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(Res.string.movie_tags_empty))
}
} else {
ShowTags(allTags, linkedTags, updateTag)
}
}
}
@@ -79,28 +67,22 @@ private fun ShowTags(
linkedTags.mapNotNull { it.id }.toSet()
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
LazyColumn(modifier = Modifier.fillMaxWidth()) {
items(
items = allTags.filter { it.id != null },
key = { tag -> tag.id ?: tag.tag }
) { tag ->
val tagId = tag.id ?: return@items
val isChecked = tagId in linkedTagIds
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { updateTag(tag.tag, !isChecked) }
.padding(vertical = 8.dp),
.clickable { updateTag(tag.tag, !isChecked) },
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = tag.tag,
modifier = Modifier.weight(1f)
.padding(start = 8.dp),
modifier = Modifier.weight(1f).padding(start = 8.dp),
style = MaterialTheme.typography.bodyLarge
)
Checkbox(
@@ -4,8 +4,8 @@ import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.launch
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.ui.nav.BaseRootComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
import ru.shadowsparky.vbox.shared.presentation.createComponentScope
import ru.shadowsparky.vbox.shared.presentation.nav.Child
@@ -48,7 +48,7 @@ class TagComponentFactory(
override fun create(
route: Route.Settings.TagOverview,
child: ComponentContext,
root: BaseRootComponent<Route>
root: RouteNavigator<Route>
): Child.TagChild {
val component = TagComponent(
child,
@@ -9,7 +9,6 @@ import androiddev.apps.vbox.client.shared.generated.resources.user_tags_new_tag_
import androiddev.apps.vbox.client.shared.generated.resources.user_tags_save
import androiddev.apps.vbox.client.shared.generated.resources.user_tags_tag_delete
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -17,12 +16,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -39,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.AppScaffold
import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.ExpressiveLazyColumn
import ru.shadowsparky.ui.components.TextPreference
import ru.shadowsparky.vbox.shared.domain.tag.TagInfo
@@ -122,35 +119,26 @@ private fun AddTagBottomSheet(
keyboardController?.show()
}
ModalBottomSheet(
AlertContent(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(true)
title = stringResource(Res.string.user_tags_add)
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stringResource(Res.string.user_tags_add),
style = MaterialTheme.typography.titleLarge
)
TextField(
value = tag,
onValueChange = { tag = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
singleLine = true
)
Button(
onClick = {
onAdd(tag.trim())
onDismiss()
},
modifier = Modifier.fillMaxWidth(),
enabled = tag.isNotBlank() && canSave(tag)
) { Text(stringResource(Res.string.user_tags_save)) }
}
TextField(
value = tag,
onValueChange = { tag = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
singleLine = true
)
Button(
onClick = {
onAdd(tag.trim())
onDismiss()
},
modifier = Modifier.fillMaxWidth(),
enabled = tag.isNotBlank() && canSave(tag)
) { Text(stringResource(Res.string.user_tags_save)) }
}
}
@@ -169,44 +157,38 @@ private fun EditTagBottomSheet(
val tagId = tag.id
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stringResource(Res.string.user_tags_edit_title),
style = MaterialTheme.typography.titleLarge
AlertContent(
onDismissRequest = onDismiss,
title = stringResource(Res.string.user_tags_edit_title)
) {
TextField(
value = newTag,
onValueChange = { newTag = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(Res.string.user_tags_new_tag)) },
singleLine = true
)
Button(
onClick = {
if (tagId != null && newTag.isNotEmpty()) {
onEdit(tag.tag, newTag)
onDismiss()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = tagId != null && newTag.isNotEmpty() && newTag != tag.tag && canSave(
newTag
)
TextField(
value = newTag,
onValueChange = { newTag = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(Res.string.user_tags_new_tag)) },
singleLine = true
)
Button(
onClick = {
if (tagId != null && newTag.isNotEmpty()) {
onEdit(tag.tag, newTag)
onDismiss()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = tagId != null && newTag.isNotEmpty() && newTag != tag.tag && canSave(
newTag
)
) { Text(stringResource(Res.string.user_tags_new_tag_save)) }
OutlinedButton(
onClick = {
if (tagId != null) {
onDelete(tag.tag)
onDismiss()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = tagId != null
) { Text(stringResource(Res.string.user_tags_tag_delete)) }
}
) { Text(stringResource(Res.string.user_tags_new_tag_save)) }
OutlinedButton(
onClick = {
if (tagId != null) {
onDelete(tag.tag)
onDismiss()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = tagId != null
) { Text(stringResource(Res.string.user_tags_tag_delete)) }
}
}
@@ -30,9 +30,9 @@ import ru.shadowsparky.koin
import ru.shadowsparky.ui.hideElementById
import ru.shadowsparky.ui.hookPageVisibility
import ru.shadowsparky.vbox.shared.presentation.Nav
import ru.shadowsparky.vbox.shared.presentation.RootComponent
import ru.shadowsparky.vbox.shared.presentation.RootFactory
import ru.shadowsparky.vbox.shared.presentation.UrlRoutes
import ru.shadowsparky.vbox.shared.presentation.VBoxRootComponent
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@KoinApplication
@@ -67,9 +67,9 @@ fun mainInternal() {
val composeRoot = document.getElementById("compose-root") as HTMLElement
var rootRef: RootComponent? = null
var rootRef: VBoxRootComponent? = null
val root: RootComponent = withWebHistory { stateKeeper: StateKeeper, deepLinkUrl: String? ->
val root: VBoxRootComponent = withWebHistory { stateKeeper: StateKeeper, deepLinkUrl: String? ->
if (rootRef == null) {
rootRef = createRoot(lifecycle, stateKeeper, deepLinkUrl)
}
@@ -103,7 +103,7 @@ private fun createRoot(
lifecycle: LifecycleRegistry,
stateKeeper: StateKeeper,
deepLinkUrl: String?,
): RootComponent {
): VBoxRootComponent {
val ctx = DefaultComponentContext(
lifecycle = lifecycle, stateKeeper = stateKeeper
)
+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
)
}
}
}