diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenFailedPreview.kt b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenFailedPreview.kt new file mode 100644 index 0000000..701ed73 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenFailedPreview.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadedPreview.kt b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadedPreview.kt new file mode 100644 index 0000000..b018f1f --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadedPreview.kt @@ -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 { + 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, + ) + ) + ), + ), + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadingPreview.kt b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadingPreview.kt new file mode 100644 index 0000000..acdb4b2 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenLoadingPreview.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenNonePreview.kt b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenNonePreview.kt new file mode 100644 index 0000000..57500c1 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/previews/screens/home/HomeScreenNonePreview.kt @@ -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 = {}, + ) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt index 6e0a6e5..abafb21 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeModel.kt @@ -13,7 +13,6 @@ import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope -import cafe.adriel.voyager.navigator.Navigator import com.aliucord.manager.BuildConfig import com.aliucord.manager.R import com.aliucord.manager.network.models.BuildInfo @@ -43,7 +42,7 @@ class HomeModel( var supportedVersion by mutableStateOf(DiscordVersion.None) private set - var installations by mutableStateOf(InstallsState.Fetching) + var state by mutableStateOf(InstallsState.Fetching) private set private var remoteDataJson: BuildInfo? = null @@ -58,7 +57,7 @@ class HomeModel( } } - fun launchApp(packageName: String) { + fun openApp(packageName: String) { val launchIntent = application.packageManager .getLaunchIntentForPackage(packageName) @@ -78,7 +77,7 @@ class HomeModel( } fun fetchInstallations() = screenModelScope.launchBlock { - installations = InstallsState.Fetching + state = InstallsState.Fetching try { val packageManager = application.packageManager @@ -120,39 +119,40 @@ class HomeModel( ) }.toImmutableList() - installations = if (aliucordInstallations.isNotEmpty()) { + state = if (aliucordInstallations.isNotEmpty()) { InstallsState.Fetched(data = aliucordInstallations) } else { InstallsState.None } } catch (t: Throwable) { 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 applicationInfo = application.packageManager.getApplicationInfo(data.packageName, 0) + val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0) val metadataFile = ZipReader(applicationInfo.publicSourceDir) .use { it.openEntry("aliucord.json")?.read() } @OptIn(ExperimentalSerializationApi::class) metadataFile?.let { json.decodeFromStream(it.inputStream()) } } 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 } val patchOptions = metadata?.options - ?: PatchOptions.Default.copy(packageName = data.packageName) + ?: PatchOptions.Default.copy(packageName = packageName) - navigator.push( - PatchOptionsScreen( - prefilledOptions = patchOptions, - supportedVersion = supportedVersion, - ) + return PatchOptionsScreen( + prefilledOptions = patchOptions, + supportedVersion = supportedVersion, ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt index 8414a1e..6b523d7 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/HomeScreen.kt @@ -8,11 +8,13 @@ package com.aliucord.manager.ui.screens.home import android.os.Parcelable 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.paddings.PaddingValuesSides import com.aliucord.manager.ui.util.paddings.exclude +import kotlinx.coroutines.launch import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -43,7 +46,9 @@ class HomeScreen : Screen, Parcelable { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow + val scope = rememberCoroutineScope() val model = getScreenModel() + val onClickInstall = remember { { navigator.push(PatchOptionsScreen(supportedVersion = model.supportedVersion)) } } // Refresh installations list when the screen changes or activity resumes LifecycleResumeEffect(Unit) { @@ -55,29 +60,37 @@ class HomeScreen : Screen, Parcelable { Scaffold( topBar = { HomeAppBar() }, ) { padding -> - when (model.installations) { - is InstallsState.Fetched -> LoadedContent( - model = model, + when (val state = model.state) { + is InstallsState.Fetched -> HomeScreenLoadedContent( + state = state, + supportedVersion = model.supportedVersion, 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( - onClickInstall = { navigator.push(PatchOptionsScreen(supportedVersion = model.supportedVersion)) }, - modifier = Modifier - .padding(padding.exclude(PaddingValuesSides.Bottom)), + InstallsState.None -> HomeScreenNoneContent( + padding = padding, + onClickInstall = onClickInstall, ) - InstallsState.Error -> FailureContent(padding = padding) + InstallsState.Error -> HomeScreenFailureContent(padding = padding) } } } } @Composable -private fun LoadingContent(padding: PaddingValues) { +fun HomeScreenLoadingContent(padding: PaddingValues) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -86,14 +99,32 @@ private fun LoadingContent(padding: PaddingValues) { .padding(top = 16.dp, start = 16.dp, end = 16.dp) ) { 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 -private fun LoadedContent( - model: HomeModel, +fun HomeScreenLoadedContent( + state: InstallsState.Fetched, + supportedVersion: DiscordVersion, padding: PaddingValues, onClickInstall: () -> Unit, + onUpdate: (packageName: String) -> Unit, + onOpenApp: (packageName: String) -> Unit, + onOpenAppInfo: (packageName: String) -> Unit, + onOpenPlugins: (packageName: String) -> Unit, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(6.dp), @@ -122,28 +153,26 @@ private fun LoadedContent( item(key = "SUPPORTED_VERSION") { AnimatedVersionDisplay( - version = model.supportedVersion, + version = supportedVersion, modifier = Modifier.padding(bottom = 30.dp), ) } - val installations = (model.installations as? InstallsState.Fetched)?.data + val installations = (state as? InstallsState.Fetched)?.data ?: return@LazyColumn items(installations, key = { it.packageName }) { AnimatedVisibility( enter = fadeIn() + slideInHorizontally { it * -2 }, exit = fadeOut() + slideOutHorizontally { it * 2 }, - visible = model.supportedVersion !is DiscordVersion.None, + visible = supportedVersion !is DiscordVersion.None, ) { - val navigator = LocalNavigator.currentOrThrow - InstalledItemCard( data = it, - onUpdate = { model.updateAliucord(it, navigator) }, - onOpenApp = { model.launchApp(it.packageName) }, - onOpenInfo = { model.openAppInfo(it.packageName) }, - onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins + onUpdate = { onUpdate(it.packageName) }, + onOpenApp = { onOpenApp(it.packageName) }, + onOpenInfo = { onOpenAppInfo(it.packageName) }, + onOpenPlugins = { onOpenPlugins(it.packageName) }, modifier = Modifier.fillMaxWidth() ) } @@ -152,7 +181,8 @@ private fun LoadedContent( } @Composable -private fun NoInstallsContent( +fun HomeScreenNoneContent( + padding: PaddingValues, onClickInstall: () -> Unit, modifier: Modifier = Modifier, ) { @@ -160,8 +190,9 @@ private fun NoInstallsContent( verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier - .fillMaxSize() - .padding(16.dp), + .padding(padding) + .padding(16.dp) + .fillMaxSize(), ) { ProjectHeader() @@ -198,7 +229,7 @@ private fun NoInstallsContent( } @Composable -fun FailureContent( +fun HomeScreenFailureContent( padding: PaddingValues, ) { Column( diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/HomeAppBar.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/HomeAppBar.kt index e5da965..28365b9 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/HomeAppBar.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/HomeAppBar.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import com.aliucord.manager.R import com.aliucord.manager.ui.screens.about.AboutScreen import com.aliucord.manager.ui.screens.settings.SettingsScreen @@ -15,16 +14,16 @@ fun HomeAppBar() { TopAppBar( title = {}, actions = { - val navigator = LocalNavigator.currentOrThrow + val navigator = LocalNavigator.current - IconButton(onClick = { navigator.push(AboutScreen()) }) { + IconButton(onClick = { navigator?.push(AboutScreen()) }) { Icon( painter = painterResource(R.drawable.ic_info), contentDescription = stringResource(R.string.navigation_about) ) } - IconButton(onClick = { navigator.push(SettingsScreen()) }) { + IconButton(onClick = { navigator?.push(SettingsScreen()) }) { Icon( painter = painterResource(R.drawable.ic_settings), contentDescription = stringResource(R.string.navigation_settings)