feat: home screen compose previews

This commit is contained in:
rushiiMachine 2025-03-28 15:41:44 -07:00
parent fdf3470973
commit bb876526d0
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
7 changed files with 237 additions and 47 deletions

View file

@ -0,0 +1,22 @@
package com.aliucord.manager.ui.previews.screens.home
import android.content.res.Configuration
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.aliucord.manager.ui.components.ManagerTheme
import com.aliucord.manager.ui.screens.home.HomeScreenFailureContent
import com.aliucord.manager.ui.screens.home.components.HomeAppBar
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenFailedPreview() {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenFailureContent(padding = padding)
}
}
}

View file

@ -0,0 +1,89 @@
package com.aliucord.manager.ui.previews.screens.home
import android.content.res.Configuration
import android.graphics.BitmapFactory
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.tooling.preview.*
import com.aliucord.manager.ui.components.ManagerTheme
import com.aliucord.manager.ui.screens.home.*
import com.aliucord.manager.ui.screens.home.components.HomeAppBar
import com.aliucord.manager.ui.util.DiscordVersion
import io.ktor.util.decodeBase64Bytes
import kotlinx.collections.immutable.persistentListOf
// This preview has animations that cannot be properly viewed from an IDE
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenLoadedPreview(
@PreviewParameter(TextBannerParametersProvider::class)
parameters: HomeScreenParameters,
) {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenLoadedContent(
state = parameters.state,
supportedVersion = parameters.supportedVersion,
padding = padding,
onClickInstall = {},
onUpdate = {},
onOpenApp = {},
onOpenAppInfo = {},
onOpenPlugins = {},
)
}
}
}
private data class HomeScreenParameters(
val state: InstallsState.Fetched,
val supportedVersion: DiscordVersion,
)
private class TextBannerParametersProvider : PreviewParameterProvider<HomeScreenParameters> {
private val stableVersion = DiscordVersion.Existing(DiscordVersion.Type.STABLE, "126.21", 126021)
private val aliucordIconBytes =
"/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAKBueIx4ZKCMgoy0qqC+8P//8Nzc8P//////////////////////////////////////////////////////////2wBDAaq0tPDS8P//////////////////////////////////////////////////////////////////////////////wAARCAC9AL0DASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAIDAf/EACIQAQEAAgEFAAIDAAAAAAAAAAABAhEhAxIxQVETMiJhcf/EABcBAQEBAQAAAAAAAAAAAAAAAAABAgP/xAAZEQEBAQEBAQAAAAAAAAAAAAAAARECEjH/2gAMAwEAAhEDEQA/AMsce7/Gkkngk1NOoxboAIAAAAAAAAAAAAAAAAAAm4ys7NXVbJyx2LKoAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZLfRcbJug4AABJugDtxs9OAAAAAAAAAAAAAA7jN0HccN81cxk9OityBZuaAVP459OyKBMifxwxw1dqAyBrYCpuEvhFmq1TnNwZsZgIyAAAAAAAANcJqM8ZutRrkAVoAAAAAAAAABnnNVLTObjNGKACAAAAAAL6c9rcxmsY6rcALdTYrlyk8kzlZeRGPTYThdzSlbC2TyMsruiW4vvimK+nedIkqwFaGN4rZnnP5DPSQEZAAAAAJ5BtPACug5n+tdAYi8sL6cmFqOeO9P2sk1NCtwYtk5Yb5gljNWH7Odt+NMce2IkjoCtiOp6WjqeIJfiAEYAAAACeYANgllnAroA5lbJxAdEzOe+FblDQAAAAcuUntyZW3iCaoAUT1PEUjOy8CX4gBGAAAAAACWzw0mf1mBLjYZS2eFTqfVa9O9TWv7Zu27u3EZtd7r9O/L64Brvdfrm79AHZ55asVY56mhZWjlykRc7Ui3pWWdqQGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHMbuOsZbLw0xy2LYoAQAAAAAAAAAAAAAAAAAARnlzqGWdnEQNSP/2Q=="
.decodeBase64Bytes()
private val aliucordIcon = BitmapFactory
.decodeByteArray(aliucordIconBytes, 0, aliucordIconBytes.size)
.asImageBitmap()
.let(::BitmapPainter)
override val values = sequenceOf(
HomeScreenParameters(
supportedVersion = stableVersion,
state = InstallsState.Fetched(
persistentListOf(
InstallData(
name = "Aliucord",
packageName = "com.aliucord",
version = stableVersion,
icon = aliucordIcon,
isUpToDate = true,
)
)
),
),
HomeScreenParameters(
supportedVersion = stableVersion,
state = InstallsState.Fetched(
persistentListOf(
InstallData(
name = "Discord",
packageName = "com.discord",
version = stableVersion,
icon = aliucordIcon,
isUpToDate = false,
)
)
),
),
)
}

View file

@ -0,0 +1,24 @@
package com.aliucord.manager.ui.previews.screens.home
import android.content.res.Configuration
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.aliucord.manager.ui.components.ManagerTheme
import com.aliucord.manager.ui.screens.home.HomeScreenLoadingContent
import com.aliucord.manager.ui.screens.home.components.HomeAppBar
// This preview cannot be properly viewed from an IDE
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenLoadingPreview() {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenLoadingContent(padding = padding)
}
}
}

View file

@ -0,0 +1,25 @@
package com.aliucord.manager.ui.previews.screens.home
import android.content.res.Configuration
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.aliucord.manager.ui.components.ManagerTheme
import com.aliucord.manager.ui.screens.home.HomeScreenNoneContent
import com.aliucord.manager.ui.screens.home.components.HomeAppBar
@Composable
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun HomeScreenNonePreview() {
ManagerTheme {
Scaffold(
topBar = { HomeAppBar() },
) { padding ->
HomeScreenNoneContent(
padding = padding,
onClickInstall = {},
)
}
}
}

View file

@ -13,7 +13,6 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri import androidx.core.net.toUri
import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import cafe.adriel.voyager.navigator.Navigator
import com.aliucord.manager.BuildConfig import com.aliucord.manager.BuildConfig
import com.aliucord.manager.R import com.aliucord.manager.R
import com.aliucord.manager.network.models.BuildInfo import com.aliucord.manager.network.models.BuildInfo
@ -43,7 +42,7 @@ class HomeModel(
var supportedVersion by mutableStateOf<DiscordVersion>(DiscordVersion.None) var supportedVersion by mutableStateOf<DiscordVersion>(DiscordVersion.None)
private set private set
var installations by mutableStateOf<InstallsState>(InstallsState.Fetching) var state by mutableStateOf<InstallsState>(InstallsState.Fetching)
private set private set
private var remoteDataJson: BuildInfo? = null private var remoteDataJson: BuildInfo? = null
@ -58,7 +57,7 @@ class HomeModel(
} }
} }
fun launchApp(packageName: String) { fun openApp(packageName: String) {
val launchIntent = application.packageManager val launchIntent = application.packageManager
.getLaunchIntentForPackage(packageName) .getLaunchIntentForPackage(packageName)
@ -78,7 +77,7 @@ class HomeModel(
} }
fun fetchInstallations() = screenModelScope.launchBlock { fun fetchInstallations() = screenModelScope.launchBlock {
installations = InstallsState.Fetching state = InstallsState.Fetching
try { try {
val packageManager = application.packageManager val packageManager = application.packageManager
@ -120,39 +119,40 @@ class HomeModel(
) )
}.toImmutableList() }.toImmutableList()
installations = if (aliucordInstallations.isNotEmpty()) { state = if (aliucordInstallations.isNotEmpty()) {
InstallsState.Fetched(data = aliucordInstallations) InstallsState.Fetched(data = aliucordInstallations)
} else { } else {
InstallsState.None InstallsState.None
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(BuildConfig.TAG, "Failed to query Aliucord installations", t) Log.e(BuildConfig.TAG, "Failed to query Aliucord installations", t)
installations = InstallsState.Error state = InstallsState.Error
} }
} }
// Launches the patching screen with prefilled options according to the existing APK /**
fun updateAliucord(data: InstallData, navigator: Navigator) = screenModelScope.launchBlock { * Creates a [PatchOptionsScreen] that can be navigated to,
* with prefilled options from an existing installation.
*/
fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen {
val metadata = try { val metadata = try {
val applicationInfo = application.packageManager.getApplicationInfo(data.packageName, 0) val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
val metadataFile = ZipReader(applicationInfo.publicSourceDir) val metadataFile = ZipReader(applicationInfo.publicSourceDir)
.use { it.openEntry("aliucord.json")?.read() } .use { it.openEntry("aliucord.json")?.read() }
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) } metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to parse Aliucord install metadata from package ${data.packageName}", t) Log.w(BuildConfig.TAG, "Failed to parse Aliucord install metadata from package ${packageName}", t)
null null
} }
val patchOptions = metadata?.options val patchOptions = metadata?.options
?: PatchOptions.Default.copy(packageName = data.packageName) ?: PatchOptions.Default.copy(packageName = packageName)
navigator.push( return PatchOptionsScreen(
PatchOptionsScreen( prefilledOptions = patchOptions,
prefilledOptions = patchOptions, supportedVersion = supportedVersion,
supportedVersion = supportedVersion,
)
) )
} }

View file

@ -8,11 +8,13 @@ package com.aliucord.manager.ui.screens.home
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -32,6 +34,7 @@ import com.aliucord.manager.ui.screens.plugins.PluginsScreen
import com.aliucord.manager.ui.util.DiscordVersion import com.aliucord.manager.ui.util.DiscordVersion
import com.aliucord.manager.ui.util.paddings.PaddingValuesSides import com.aliucord.manager.ui.util.paddings.PaddingValuesSides
import com.aliucord.manager.ui.util.paddings.exclude import com.aliucord.manager.ui.util.paddings.exclude
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -43,7 +46,9 @@ class HomeScreen : Screen, Parcelable {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val model = getScreenModel<HomeModel>() val model = getScreenModel<HomeModel>()
val onClickInstall = remember { { navigator.push(PatchOptionsScreen(supportedVersion = model.supportedVersion)) } }
// Refresh installations list when the screen changes or activity resumes // Refresh installations list when the screen changes or activity resumes
LifecycleResumeEffect(Unit) { LifecycleResumeEffect(Unit) {
@ -55,29 +60,37 @@ class HomeScreen : Screen, Parcelable {
Scaffold( Scaffold(
topBar = { HomeAppBar() }, topBar = { HomeAppBar() },
) { padding -> ) { padding ->
when (model.installations) { when (val state = model.state) {
is InstallsState.Fetched -> LoadedContent( is InstallsState.Fetched -> HomeScreenLoadedContent(
model = model, state = state,
supportedVersion = model.supportedVersion,
padding = padding, padding = padding,
onClickInstall = { navigator.push(PatchOptionsScreen(supportedVersion = model.supportedVersion)) }, onClickInstall = onClickInstall,
onUpdate = {
scope.launch {
navigator.push(model.createPrefilledPatchOptsScreen(it))
}
},
onOpenApp = model::openApp,
onOpenAppInfo = model::openAppInfo,
onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins
) )
InstallsState.Fetching -> LoadingContent(padding = padding) InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding)
InstallsState.None -> NoInstallsContent( InstallsState.None -> HomeScreenNoneContent(
onClickInstall = { navigator.push(PatchOptionsScreen(supportedVersion = model.supportedVersion)) }, padding = padding,
modifier = Modifier onClickInstall = onClickInstall,
.padding(padding.exclude(PaddingValuesSides.Bottom)),
) )
InstallsState.Error -> FailureContent(padding = padding) InstallsState.Error -> HomeScreenFailureContent(padding = padding)
} }
} }
} }
} }
@Composable @Composable
private fun LoadingContent(padding: PaddingValues) { fun HomeScreenLoadingContent(padding: PaddingValues) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
@ -86,14 +99,32 @@ private fun LoadingContent(padding: PaddingValues) {
.padding(top = 16.dp, start = 16.dp, end = 16.dp) .padding(top = 16.dp, start = 16.dp, end = 16.dp)
) { ) {
ProjectHeader() ProjectHeader()
AnimatedVisibility(
visibleState = remember { MutableTransitionState(false) }.apply { targetState = true },
enter = fadeIn(animationSpec = tween(durationMillis = 800)),
exit = ExitTransition.None,
) {
Box(
contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() },
modifier = Modifier
.fillMaxSize(),
)
}
} }
} }
@Composable @Composable
private fun LoadedContent( fun HomeScreenLoadedContent(
model: HomeModel, state: InstallsState.Fetched,
supportedVersion: DiscordVersion,
padding: PaddingValues, padding: PaddingValues,
onClickInstall: () -> Unit, onClickInstall: () -> Unit,
onUpdate: (packageName: String) -> Unit,
onOpenApp: (packageName: String) -> Unit,
onOpenAppInfo: (packageName: String) -> Unit,
onOpenPlugins: (packageName: String) -> Unit,
) { ) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
@ -122,28 +153,26 @@ private fun LoadedContent(
item(key = "SUPPORTED_VERSION") { item(key = "SUPPORTED_VERSION") {
AnimatedVersionDisplay( AnimatedVersionDisplay(
version = model.supportedVersion, version = supportedVersion,
modifier = Modifier.padding(bottom = 30.dp), modifier = Modifier.padding(bottom = 30.dp),
) )
} }
val installations = (model.installations as? InstallsState.Fetched)?.data val installations = (state as? InstallsState.Fetched)?.data
?: return@LazyColumn ?: return@LazyColumn
items(installations, key = { it.packageName }) { items(installations, key = { it.packageName }) {
AnimatedVisibility( AnimatedVisibility(
enter = fadeIn() + slideInHorizontally { it * -2 }, enter = fadeIn() + slideInHorizontally { it * -2 },
exit = fadeOut() + slideOutHorizontally { it * 2 }, exit = fadeOut() + slideOutHorizontally { it * 2 },
visible = model.supportedVersion !is DiscordVersion.None, visible = supportedVersion !is DiscordVersion.None,
) { ) {
val navigator = LocalNavigator.currentOrThrow
InstalledItemCard( InstalledItemCard(
data = it, data = it,
onUpdate = { model.updateAliucord(it, navigator) }, onUpdate = { onUpdate(it.packageName) },
onOpenApp = { model.launchApp(it.packageName) }, onOpenApp = { onOpenApp(it.packageName) },
onOpenInfo = { model.openAppInfo(it.packageName) }, onOpenInfo = { onOpenAppInfo(it.packageName) },
onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins onOpenPlugins = { onOpenPlugins(it.packageName) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@ -152,7 +181,8 @@ private fun LoadedContent(
} }
@Composable @Composable
private fun NoInstallsContent( fun HomeScreenNoneContent(
padding: PaddingValues,
onClickInstall: () -> Unit, onClickInstall: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -160,8 +190,9 @@ private fun NoInstallsContent(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier modifier = modifier
.fillMaxSize() .padding(padding)
.padding(16.dp), .padding(16.dp)
.fillMaxSize(),
) { ) {
ProjectHeader() ProjectHeader()
@ -198,7 +229,7 @@ private fun NoInstallsContent(
} }
@Composable @Composable
fun FailureContent( fun HomeScreenFailureContent(
padding: PaddingValues, padding: PaddingValues,
) { ) {
Column( Column(

View file

@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.aliucord.manager.R import com.aliucord.manager.R
import com.aliucord.manager.ui.screens.about.AboutScreen import com.aliucord.manager.ui.screens.about.AboutScreen
import com.aliucord.manager.ui.screens.settings.SettingsScreen import com.aliucord.manager.ui.screens.settings.SettingsScreen
@ -15,16 +14,16 @@ fun HomeAppBar() {
TopAppBar( TopAppBar(
title = {}, title = {},
actions = { actions = {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.current
IconButton(onClick = { navigator.push(AboutScreen()) }) { IconButton(onClick = { navigator?.push(AboutScreen()) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_info), painter = painterResource(R.drawable.ic_info),
contentDescription = stringResource(R.string.navigation_about) contentDescription = stringResource(R.string.navigation_about)
) )
} }
IconButton(onClick = { navigator.push(SettingsScreen()) }) { IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_settings), painter = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings) contentDescription = stringResource(R.string.navigation_settings)