mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-21 16:32:39 +00:00
878 lines
33 KiB
Kotlin
878 lines
33 KiB
Kotlin
package com.geode.launcher
|
|
|
|
import android.content.Intent
|
|
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
|
import android.os.Bundle
|
|
import android.text.format.Formatter
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.compose.setContent
|
|
import androidx.activity.enableEdgeToEdge
|
|
import androidx.annotation.DrawableRes
|
|
import androidx.annotation.StringRes
|
|
import androidx.compose.animation.Crossfade
|
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
import androidx.compose.animation.core.LinearEasing
|
|
import androidx.compose.animation.core.RepeatMode
|
|
import androidx.compose.animation.core.animateFloat
|
|
import androidx.compose.animation.core.infiniteRepeatable
|
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.isSystemInDarkTheme
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
import androidx.compose.foundation.layout.FlowRow
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.FilledTonalButton
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.LocalTextStyle
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Scaffold
|
|
import androidx.compose.material3.SnackbarDuration
|
|
import androidx.compose.material3.SnackbarHost
|
|
import androidx.compose.material3.SnackbarHostState
|
|
import androidx.compose.material3.SnackbarResult
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.CompositionLocalProvider
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.drawWithContent
|
|
import androidx.compose.ui.geometry.Offset
|
|
import androidx.compose.ui.graphics.BlendMode
|
|
import androidx.compose.ui.graphics.Brush
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.CompositingStrategy
|
|
import androidx.compose.ui.graphics.TileMode
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.platform.LocalResources
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.tooling.preview.Preview
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
import com.geode.launcher.main.*
|
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
|
import com.geode.launcher.ui.theme.LocalTheme
|
|
import com.geode.launcher.ui.theme.Theme
|
|
import com.geode.launcher.ui.theme.Theme.DARK
|
|
import com.geode.launcher.ui.theme.Theme.LIGHT
|
|
import com.geode.launcher.ui.theme.launcherTitleStyle
|
|
import com.geode.launcher.ui.theme.sapphireTitleStyle
|
|
import com.geode.launcher.updater.ReleaseManager
|
|
import com.geode.launcher.utils.ApplicationIcon
|
|
import com.geode.launcher.utils.Constants
|
|
import com.geode.launcher.utils.GamePackageUtils
|
|
import com.geode.launcher.utils.LaunchUtils
|
|
import com.geode.launcher.utils.PreferenceUtils
|
|
import kotlinx.coroutines.CancellationException
|
|
import kotlinx.coroutines.launch
|
|
import java.net.ConnectException
|
|
import java.net.UnknownHostException
|
|
|
|
class MainActivity : ComponentActivity() {
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
|
|
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
|
|
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
|
|
|
|
val restartImmediately = intent.getBooleanExtra("restarted", false)
|
|
if (gdInstalled && geodeInstalled && restartImmediately) {
|
|
val withSafeMode = intent.getBooleanExtra("safe-mode", false)
|
|
onLaunch(this, withSafeMode)
|
|
}
|
|
|
|
enableEdgeToEdge()
|
|
clearDownloadedApks(this)
|
|
|
|
// remove old Geode for the like 4 people that downloaded the Play Store build
|
|
@Suppress("SENSELESS_COMPARISON")
|
|
if (BuildConfig.PREBUNDLED_GEODE != null) {
|
|
val preferences = PreferenceUtils.get(this)
|
|
if (!preferences.getBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED)) {
|
|
LaunchUtils.getInstalledGeodePath(this)?.delete()
|
|
preferences.setBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED, true)
|
|
}
|
|
}
|
|
|
|
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
|
|
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
|
|
|
|
@Suppress("DEPRECATION") // the new api is android 13 only (why)
|
|
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
|
|
as? LaunchUtils.LauncherError
|
|
|
|
val loadFailureInfo = if (returnError != null && returnMessage != null) {
|
|
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
|
|
} else if (LaunchUtils.lastSessionCrashed(this)) {
|
|
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
|
|
} else { null }
|
|
|
|
var launchArguments: LaunchArguments? = null
|
|
val intentData = intent.data
|
|
if (intent.action == Intent.ACTION_VIEW && intentData != null) {
|
|
val isLaunch = intentData.path == "/launch"
|
|
val safeMode = if (isLaunch) {
|
|
intentData.getBooleanQueryParameter("safe-mode", false)
|
|
} else false
|
|
|
|
launchArguments = LaunchArguments(
|
|
autoSafeMode = safeMode,
|
|
forcePause = !isLaunch,
|
|
forceLaunch = isLaunch,
|
|
)
|
|
}
|
|
|
|
setContent {
|
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
|
val theme = Theme.fromInt(themeOption)
|
|
|
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
|
|
|
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
|
|
|
|
if (loadFailureInfo != null) {
|
|
launchViewModel.loadFailure = loadFailureInfo
|
|
}
|
|
|
|
if (launchArguments != null) {
|
|
launchViewModel.launchArguments = launchArguments
|
|
}
|
|
|
|
launchViewModel.beginLaunchFlow()
|
|
|
|
CompositionLocalProvider(LocalTheme provides theme) {
|
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
|
Surface(
|
|
modifier = Modifier.fillMaxSize()
|
|
) {
|
|
AltMainScreen(launchViewModel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
data class LaunchStatusInfo(
|
|
val title: String?,
|
|
val details: String? = null,
|
|
val progress: (() -> Float)? = null
|
|
)
|
|
|
|
@Composable
|
|
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
|
|
when (cancelReason) {
|
|
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
|
|
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
|
|
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
|
|
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
|
|
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
|
|
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
|
|
return when (cancelReason) {
|
|
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
|
|
title = stringResource(R.string.launcher_cancelled_crash)
|
|
)
|
|
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC -> LaunchStatusInfo(
|
|
title = stringResource(R.string.launcher_cancelled_crash_instant)
|
|
)
|
|
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
|
|
title = stringResource(R.string.geode_download_title)
|
|
)
|
|
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
|
|
title = stringResource(R.string.launcher_cancelled_error),
|
|
details = stringResource(R.string.game_not_found)
|
|
)
|
|
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
|
|
title = stringResource(R.string.launcher_cancelled_error),
|
|
details = stringResource(R.string.launcher_cancelled_outdated, GamePackageUtils.getSupportedUnifiedVersion())
|
|
)
|
|
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
|
|
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
|
|
title = null
|
|
)
|
|
/*
|
|
else -> LaunchStatusInfo(
|
|
title = stringResource(R.string.launcher_cancelled_manual)
|
|
)
|
|
*/
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
|
|
return when (state) {
|
|
is LaunchViewModel.LaunchUIState.Initial,
|
|
is LaunchViewModel.LaunchUIState.Working,
|
|
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
|
|
title = stringResource(R.string.launcher_starting_game),
|
|
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
|
|
)
|
|
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
|
|
title = stringResource(R.string.release_fetch_in_progress)
|
|
)
|
|
is LaunchViewModel.LaunchUIState.Updating -> {
|
|
val context = LocalContext.current
|
|
|
|
val downloaded = remember(state.downloaded) {
|
|
Formatter.formatShortFileSize(context, state.downloaded)
|
|
}
|
|
|
|
val outOf = remember(state.outOf) {
|
|
state.outOf?.run {
|
|
Formatter.formatShortFileSize(context, this)
|
|
}
|
|
}
|
|
|
|
LaunchStatusInfo(
|
|
title = stringResource(R.string.launcher_downloading_update),
|
|
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf ?: "…"),
|
|
progress = {
|
|
val progress = state.downloaded / state.outOf!!.toDouble()
|
|
progress.toFloat()
|
|
}.takeIf { state.outOf != null }
|
|
)
|
|
}
|
|
is LaunchViewModel.LaunchUIState.Cancelled -> {
|
|
mapCancelReasonToInfo(state.reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
|
|
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
|
|
return
|
|
}
|
|
|
|
Column(
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
modifier = modifier.width(300.dp)
|
|
) {
|
|
if (statusInfo.title != null) {
|
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
|
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
|
|
icon()
|
|
Spacer(Modifier.size(8.dp))
|
|
Text(statusInfo.title)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (inProgress) {
|
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
}
|
|
|
|
if (statusInfo.details != null) {
|
|
Text(
|
|
statusInfo.details,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
textAlign = TextAlign.Center
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
@Composable
|
|
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
|
|
Column(
|
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
horizontalAlignment = Alignment.Start,
|
|
modifier = modifier.width(300.dp)
|
|
) {
|
|
if (statusInfo.title != null) {
|
|
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
|
|
}
|
|
|
|
if (statusInfo.progress != null) {
|
|
LinearProgressIndicator(
|
|
statusInfo.progress,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
} else {
|
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
|
}
|
|
|
|
if (statusInfo.details != null) {
|
|
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
|
|
when (reason) {
|
|
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
|
|
Icon(
|
|
painterResource(R.drawable.icon_resume),
|
|
contentDescription = null
|
|
)
|
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
Text(stringResource(R.string.launcher_cancelled_resume))
|
|
}
|
|
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
|
|
LaunchViewModel.LaunchCancelReason.MANUAL,
|
|
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
|
|
Icon(
|
|
painterResource(R.drawable.icon_play_arrow),
|
|
contentDescription = null
|
|
)
|
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
Text(stringResource(R.string.launcher_launch))
|
|
}
|
|
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
|
|
Icon(
|
|
painterResource(R.drawable.icon_refresh),
|
|
contentDescription = null
|
|
)
|
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
Text(stringResource(R.string.launcher_cancelled_retry))
|
|
}
|
|
// these shouldn't be allowing retries anyways
|
|
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
|
|
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalLayoutApi::class)
|
|
@Composable
|
|
fun LaunchProgressCard(
|
|
uiState: LaunchViewModel.LaunchUIState,
|
|
crashInfo: LoadFailureInfo?,
|
|
onCancel: () -> Unit,
|
|
onResume: (safeMode: Boolean) -> Unit,
|
|
onMore: () -> Unit,
|
|
extraOptions: @Composable () -> Unit,
|
|
safeModeEnabled: Boolean,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
var showSafeModeDialog by remember { mutableStateOf(false) }
|
|
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
|
|
|
|
Box(modifier = modifier) {
|
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
|
|
LaunchCancelledBody(
|
|
statusInfo = status,
|
|
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
|
|
inProgress = uiState.inProgress
|
|
)
|
|
|
|
if (uiState.reason.isGameInstallIssue()) {
|
|
val context = LocalContext.current
|
|
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
|
|
|
|
if (showDownload) {
|
|
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
|
|
}
|
|
}
|
|
|
|
if (uiState.reason.allowsRetry()) {
|
|
FlowRow(
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
|
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
|
) {
|
|
LongPressButton(onClick = { onResume(false) }, onLongPress = {
|
|
showSafeModeDialog = true
|
|
}) {
|
|
RetryButtonContents(uiState.reason)
|
|
}
|
|
|
|
if (crashInfo != null) {
|
|
FilledTonalButton(onClick = onMore) {
|
|
Icon(
|
|
painterResource(R.drawable.icon_question_mark),
|
|
contentDescription = null
|
|
)
|
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
Text(stringResource(R.string.launcher_cancelled_help_alt))
|
|
}
|
|
}
|
|
|
|
extraOptions()
|
|
}
|
|
}
|
|
} else {
|
|
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
|
|
|
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
|
|
TextButton(onClick = onCancel) {
|
|
Text(stringResource(R.string.release_fetch_button_cancel))
|
|
}
|
|
|
|
Spacer(Modifier.weight(1.0f))
|
|
|
|
extraOptions()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (showSafeModeDialog) {
|
|
SafeModeDialog(
|
|
onDismiss = {
|
|
showSafeModeDialog = false
|
|
},
|
|
onLaunch = {
|
|
onResume(true)
|
|
showSafeModeDialog = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ExtraOptions(onSettings: () -> Unit) {
|
|
IconButton(
|
|
onClick = onSettings,
|
|
) {
|
|
Icon(
|
|
painterResource(R.drawable.icon_settings),
|
|
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
|
|
)
|
|
}
|
|
}
|
|
|
|
data class ColorPalette(
|
|
val outerColors: List<Pair<Float, Color>>,
|
|
val innerColors: List<Pair<Float, Color>>,
|
|
)
|
|
|
|
data class AnimatedIconSpec(
|
|
val lightPalette: ColorPalette,
|
|
val darkPalette: ColorPalette,
|
|
|
|
@param:DrawableRes val innerId: Int,
|
|
@param:DrawableRes val outerId: Int,
|
|
)
|
|
|
|
data class BrandPalette(
|
|
@param:DrawableRes val darkLogo: Int,
|
|
@param:DrawableRes val lightLogo: Int,
|
|
|
|
@param:StringRes val title: Int,
|
|
val titleFont: TextStyle,
|
|
val animatedIcon: AnimatedIconSpec? = null,
|
|
)
|
|
|
|
val geodeColorPalette = BrandPalette(
|
|
animatedIcon = AnimatedIconSpec(
|
|
lightPalette = ColorPalette(
|
|
outerColors = listOf(
|
|
0.0f to Color(0xfff5b11d),
|
|
0.10f to Color(0xfff0834d),
|
|
0.24f to Color(0xffe85f6b),
|
|
0.49f to Color(0xffc9659e),
|
|
1.0f to Color(0xffb9588f)
|
|
),
|
|
innerColors = listOf(
|
|
0.48f to Color(0xfff2ce00),
|
|
0.63f to Color(0xfff4b97e),
|
|
0.73f to Color(0xfff7b66a),
|
|
1.0f to Color(0xffeb8fac)
|
|
)
|
|
),
|
|
darkPalette = ColorPalette(
|
|
outerColors = listOf(
|
|
0.0f to Color(0xfffffdff),
|
|
0.08f to Color(0xffF2EAF5),
|
|
0.12f to Color(0xffEDE5EF),
|
|
0.31f to Color(0xffCDB5CD),
|
|
0.49f to Color(0xffBA9BBC),
|
|
1.0f to Color(0xff8D7ACF),
|
|
),
|
|
innerColors = listOf(
|
|
0.48f to Color(0xffffffff),
|
|
0.63f to Color(0xfff5e4c2),
|
|
0.73f to Color(0xffe5c7ad),
|
|
1.0f to Color(0xffb790a9),
|
|
)
|
|
),
|
|
innerId = R.drawable.geode_monochrome_inner,
|
|
outerId = R.drawable.geode_monochrome_outer,
|
|
),
|
|
darkLogo = R.drawable.geode_base,
|
|
lightLogo = R.drawable.geode_base_light,
|
|
title = R.string.launcher_title,
|
|
titleFont = launcherTitleStyle
|
|
)
|
|
|
|
val sapphireColorPalette = BrandPalette(
|
|
darkLogo = R.drawable.sapphire_full,
|
|
lightLogo = R.drawable.sapphire_full,
|
|
title = R.string.application_icon_sapphire,
|
|
titleFont = sapphireTitleStyle
|
|
)
|
|
|
|
@Preview(name = "non-animated, light")
|
|
@Preview(name = "non-animated, dark", uiMode = UI_MODE_NIGHT_YES)
|
|
@Composable
|
|
fun GeodeLogoPreview() {
|
|
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
|
|
CompositionLocalProvider(LocalTheme provides theme) {
|
|
GeodeLauncherTheme(theme = theme) {
|
|
Surface {
|
|
GeodeLogo()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Preview(name = "animated, light")
|
|
@Preview(name = "animated, dark", uiMode = UI_MODE_NIGHT_YES)
|
|
@Composable
|
|
fun GeodeLogoAnimatedPreview() {
|
|
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
|
|
CompositionLocalProvider(LocalTheme provides theme) {
|
|
GeodeLauncherTheme(theme = theme) {
|
|
Surface {
|
|
GeodeLogo(shouldAnimate = true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Preview(name = "sapphire, non-animated, light")
|
|
@Preview(name = "sapphire, non-animated, dark", uiMode = UI_MODE_NIGHT_YES)
|
|
@Composable
|
|
fun SapphireLogoPreview() {
|
|
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
|
|
CompositionLocalProvider(LocalTheme provides theme) {
|
|
GeodeLauncherTheme(theme = theme) {
|
|
Surface {
|
|
GeodeLogo(shouldAnimate = false, basePalette = sapphireColorPalette)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Preview(name = "sapphire, animated, light")
|
|
@Preview(name = "sapphire, animated, dark", uiMode = UI_MODE_NIGHT_YES)
|
|
@Composable
|
|
fun SapphireLogoAnimatedPreview() {
|
|
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
|
|
CompositionLocalProvider(LocalTheme provides theme) {
|
|
GeodeLauncherTheme(theme = theme) {
|
|
Surface {
|
|
GeodeLogo(shouldAnimate = true, basePalette = sapphireColorPalette)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun AnimatedLogo(modifier: Modifier = Modifier, spec: AnimatedIconSpec) {
|
|
val theme = LocalTheme.current
|
|
|
|
val colorPalette = if (theme == LIGHT) spec.lightPalette
|
|
else spec.darkPalette
|
|
|
|
val innerColorPalette = colorPalette.innerColors
|
|
val outerColorPalette = colorPalette.outerColors
|
|
|
|
val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
|
|
val offset by infiniteTransition.animateFloat(
|
|
initialValue = -1.0f,
|
|
targetValue = 1.0f,
|
|
animationSpec = infiniteRepeatable(
|
|
animation = tween(2000, easing = LinearEasing),
|
|
repeatMode = RepeatMode.Restart
|
|
),
|
|
label = "gradient offset"
|
|
)
|
|
|
|
val innerBrush = Brush.linearGradient(
|
|
colorStops = innerColorPalette.toTypedArray(),
|
|
start = Offset(0.0f, 0.0f + (165.06f * offset)),
|
|
end = Offset(0.0f, 165.06f + (165.06f * offset)),
|
|
tileMode = TileMode.Mirror
|
|
)
|
|
|
|
val outerBrush = Brush.linearGradient(
|
|
colorStops = outerColorPalette.toTypedArray(),
|
|
start = Offset(0.0f + (84.69f * offset), 0.0f + (165.06f * offset)),
|
|
end = Offset(84.69f + (84.69f * offset), 169.61f + (165.06f * offset)),
|
|
tileMode = TileMode.Mirror
|
|
)
|
|
|
|
Box(modifier = modifier) {
|
|
Icon(
|
|
painter = painterResource(spec.innerId),
|
|
contentDescription = null,
|
|
modifier = Modifier
|
|
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
|
.drawWithContent {
|
|
drawContent()
|
|
drawRect(
|
|
brush = innerBrush,
|
|
blendMode = BlendMode.SrcIn
|
|
)
|
|
}
|
|
.size(64.dp, 64.dp)
|
|
)
|
|
Icon(
|
|
painter = painterResource(spec.outerId),
|
|
contentDescription = null,
|
|
modifier = Modifier
|
|
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
|
.drawWithContent {
|
|
drawContent()
|
|
drawRect(
|
|
brush = outerBrush,
|
|
blendMode = BlendMode.SrcIn
|
|
)
|
|
}
|
|
.size(64.dp, 64.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun GeodeLogo(modifier: Modifier = Modifier, shouldAnimate: Boolean = false, basePalette: BrandPalette = geodeColorPalette) {
|
|
FlowRow(
|
|
horizontalArrangement = Arrangement.Center,
|
|
itemVerticalAlignment = Alignment.CenterVertically,
|
|
modifier = modifier
|
|
) {
|
|
val theme = LocalTheme.current
|
|
|
|
if (basePalette.animatedIcon != null) {
|
|
Crossfade(
|
|
targetState = shouldAnimate,
|
|
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
|
|
label="logo fade"
|
|
) { screen ->
|
|
when (screen) {
|
|
true -> AnimatedLogo(modifier = Modifier.size(64.dp, 64.dp), spec = basePalette.animatedIcon)
|
|
false -> Image(
|
|
painterResource(if (theme == LIGHT)
|
|
basePalette.lightLogo else basePalette.darkLogo
|
|
),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(64.dp, 64.dp)
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
Image(
|
|
painterResource(if (theme == LIGHT)
|
|
basePalette.lightLogo else basePalette.darkLogo
|
|
),
|
|
contentDescription = null,
|
|
modifier = Modifier.size(84.dp, 84.dp)
|
|
)
|
|
}
|
|
|
|
Text(
|
|
stringResource(basePalette.title),
|
|
style = basePalette.titleFont,
|
|
fontSize = 64.sp,
|
|
modifier = Modifier
|
|
.padding(12.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Unit) {
|
|
val context = LocalContext.current
|
|
val resources = LocalResources.current
|
|
|
|
val releaseState = ReleaseManager.get(context).uiState.collectAsState().value
|
|
|
|
if (releaseState !is ReleaseManager.ReleaseManagerState.Failure) {
|
|
return
|
|
}
|
|
|
|
val message = when (releaseState.exception) {
|
|
is UnknownHostException, is ConnectException ->
|
|
stringResource(R.string.release_fetch_no_internet_short)
|
|
is ReleaseManager.UpdateException -> {
|
|
when (releaseState.exception.reason) {
|
|
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
|
|
R.string.release_fetch_external_in_use_short
|
|
)
|
|
ReleaseManager.UpdateException.Reason.HASH_VALIDATION_FAILED -> stringResource(
|
|
R.string.release_fetch_hash_validation_failed_short
|
|
)
|
|
else -> stringResource(R.string.release_fetch_generic_short)
|
|
}
|
|
}
|
|
is CancellationException -> null
|
|
else -> stringResource(R.string.release_fetch_generic_short)
|
|
}
|
|
|
|
if (message == null) {
|
|
return
|
|
}
|
|
|
|
LaunchedEffect(message) {
|
|
val result = snackbarHostState.showSnackbar(
|
|
message = message,
|
|
actionLabel = resources.getString(R.string.release_fetch_button_retry),
|
|
duration = SnackbarDuration.Indefinite,
|
|
withDismissAction = true
|
|
)
|
|
|
|
when (result) {
|
|
SnackbarResult.ActionPerformed -> {
|
|
onRetry()
|
|
}
|
|
else -> {}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun AltMainScreen(
|
|
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
|
|
) {
|
|
val context = LocalContext.current
|
|
|
|
val launchUIState by launchViewModel.uiState.collectAsState()
|
|
val coroutineScope = rememberCoroutineScope()
|
|
var launchInSafeMode by remember {
|
|
mutableStateOf(launchViewModel.launchArguments?.autoSafeMode == true)
|
|
}
|
|
|
|
var showErrorInfo by remember { mutableStateOf(false) }
|
|
|
|
val snackbarHostState = remember { SnackbarHostState() }
|
|
|
|
Scaffold(
|
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
|
modifier = Modifier.safeDrawingPadding()
|
|
) { innerPadding ->
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(innerPadding),
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Column(
|
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
modifier = Modifier
|
|
.padding(8.dp)
|
|
.verticalScroll(rememberScrollState())
|
|
) {
|
|
val sapphireLogo = remember {
|
|
PreferenceUtils.get(context)
|
|
.getString(PreferenceUtils.Key.SELECTED_ICON) == ApplicationIcon.SAPPHIRE.toId()
|
|
}
|
|
|
|
val basePalette = if (sapphireLogo) sapphireColorPalette
|
|
else geodeColorPalette
|
|
GeodeLogo(shouldAnimate = launchUIState.isInProgress(), basePalette = basePalette)
|
|
|
|
Column(
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
LaunchProgressCard(
|
|
launchUIState,
|
|
crashInfo = launchViewModel.currentCrashInfo(),
|
|
onCancel = {
|
|
launchInSafeMode = false
|
|
coroutineScope.launch {
|
|
launchViewModel.cancelLaunch()
|
|
}
|
|
},
|
|
onResume = { safeMode ->
|
|
launchInSafeMode = safeMode
|
|
|
|
launchViewModel.clearCrashInfo()
|
|
coroutineScope.launch {
|
|
launchViewModel.beginLaunchFlow(true)
|
|
}
|
|
},
|
|
onMore = {
|
|
showErrorInfo = true
|
|
},
|
|
extraOptions = {
|
|
ExtraOptions(
|
|
onSettings = {
|
|
coroutineScope.launch {
|
|
launchViewModel.cancelLaunch(true)
|
|
}
|
|
|
|
onSettings(context)
|
|
}
|
|
)
|
|
},
|
|
safeModeEnabled = launchInSafeMode
|
|
)
|
|
|
|
// only show launcher update in a case where a user won't see it ingame
|
|
val currentUIState = launchUIState
|
|
if (currentUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
|
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
|
|
|
|
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING && currentUIState.reason != LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
|
|
UnsupportedVersionWarning()
|
|
}
|
|
|
|
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
|
|
if (nextLauncherUpdate != null) {
|
|
LauncherUpdateIndicator()
|
|
}
|
|
|
|
GeodeUpdateIndicator(snackbarHostState) {
|
|
launchViewModel.retryUpdate()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
|
|
UpdateWarning(launchInSafeMode) {
|
|
launchInSafeMode = false
|
|
coroutineScope.launch {
|
|
launchViewModel.cancelLaunch()
|
|
}
|
|
}
|
|
}
|
|
|
|
val loadFailure = launchViewModel.currentCrashInfo()
|
|
if (showErrorInfo && loadFailure != null) {
|
|
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
|
|
}
|
|
} |