diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5940a1c3..6323ea30 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,10 +91,11 @@ android { jvmTarget = "11" freeCompilerArgs += listOf( "-Xcontext-receivers", - "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", - "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", -// "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${reportsDir}", + "-opt-in=androidx.compose.animation.ExperimentalAnimationApi", + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi", + "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", + "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${reportsDir}", ) } @@ -106,6 +107,10 @@ android { composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } + + lint { + disable += "ModifierParameter" + } } dependencies { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4cf3355..48268971 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,20 +4,14 @@ + - - - diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/components/ProjectHeader.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/ProjectHeader.kt new file mode 100644 index 00000000..3f5721ba --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/components/ProjectHeader.kt @@ -0,0 +1,70 @@ +package com.aliucord.manager.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aliucord.manager.BuildConfig +import com.aliucord.manager.R + +@Composable +fun ProjectHeader(modifier: Modifier = Modifier) { + val uriHandler = LocalUriHandler.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + Image( + painter = painterResource(R.drawable.ic_aliucord_logo), + contentDescription = null, + modifier = Modifier + .size(88.dp) + .padding(bottom = 8.dp), + ) + + Text( + text = stringResource(R.string.aliucord), + style = MaterialTheme.typography.titleMedium.copy(fontSize = 26.sp) + ) + + Text( + text = stringResource(R.string.app_description), + style = MaterialTheme.typography.titleSmall.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f) + ) + ) + + Row( + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { uriHandler.openUri("https://github.com/Aliucord") }) { + Icon( + painter = painterResource(R.drawable.ic_account_github_white_24dp), + contentDescription = null, + modifier = Modifier.padding(end = ButtonDefaults.IconSpacing), + ) + Text(text = stringResource(R.string.github)) + } + + TextButton(onClick = { uriHandler.openUri("https://discord.gg/${BuildConfig.SUPPORT_SERVER}") }) { + Icon( + painter = painterResource(R.drawable.ic_discord), + contentDescription = stringResource(R.string.support_server), + modifier = Modifier + .padding(end = ButtonDefaults.IconSpacing) + .size(22.dp), + ) + Text(text = stringResource(R.string.discord)) + } + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/components/SegmentedButton.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/SegmentedButton.kt new file mode 100644 index 00000000..bb4b2385 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/components/SegmentedButton.kt @@ -0,0 +1,45 @@ +package com.aliucord.manager.ui.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp + +@Composable +fun RowScope.SegmentedButton( + icon: Painter, + iconColor: Color = MaterialTheme.colorScheme.primary, + iconDescription: String? = null, + text: String, + textColor: Color = MaterialTheme.colorScheme.primary, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + modifier = Modifier + .clickable(onClick = onClick) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(LocalAbsoluteTonalElevation.current + 2.dp)) + .weight(1f) + .padding(12.dp) + ) { + Icon( + painter = icon, + contentDescription = iconDescription, + tint = iconColor, + ) + + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = textColor, + maxLines = 1, + modifier = Modifier.basicMarquee(), + ) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/components/home/InfoCard.kt b/app/src/main/kotlin/com/aliucord/manager/ui/components/home/InfoCard.kt deleted file mode 100644 index e4201829..00000000 --- a/app/src/main/kotlin/com/aliucord/manager/ui/components/home/InfoCard.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.aliucord.manager.ui.components.home - -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.aliucord.manager.R -import com.aliucord.manager.ui.util.DiscordVersion - -@Composable -fun InfoCard( - packageName: String, - supportedVersion: DiscordVersion, - currentVersion: DiscordVersion, - onDownloadClick: () -> Unit, - onUninstallClick: () -> Unit, - onLaunchClick: () -> Unit, -) { - ElevatedCard { - Column( - modifier = Modifier - .padding(20.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "${stringResource(R.string.aliucord)} ($packageName)", - style = MaterialTheme.typography.titleLarge.copy(fontSize = 23.sp), - color = MaterialTheme.colorScheme.primary - ) - - Text( - buildAnnotatedString { - append(stringResource(R.string.version_supported)) - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(" ") - if (supportedVersion is DiscordVersion.Existing) { - append(supportedVersion.name) - append(" - ") - } - append(supportedVersion.toDisplayName()) - } - - append('\n') - append(stringResource(R.string.version_installed)) - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(" ") - if (currentVersion is DiscordVersion.Existing) { - append(currentVersion.name) - append(" - ") - } - append(currentVersion.toDisplayName()) - } - }, - style = MaterialTheme.typography.bodyMedium - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - val (icon, description) = when { - currentVersion !is DiscordVersion.Existing -> - R.drawable.ic_download to R.string.action_install - - currentVersion < supportedVersion -> - R.drawable.ic_refresh to R.string.action_reinstall - - else -> - R.drawable.ic_update to R.string.action_update - } - - FilledTonalIconButton( - modifier = Modifier - .weight(1f) - .heightIn(min = 50.dp), - onClick = onDownloadClick, - shape = ShapeDefaults.Large, - enabled = supportedVersion !is DiscordVersion.Error - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(icon), - contentDescription = stringResource(description) - ) - if (currentVersion is DiscordVersion.None) { - Text( - stringResource(description), - style = MaterialTheme.typography.labelLarge - ) - } - } - } - - if (currentVersion is DiscordVersion.Existing) { - FilledTonalIconButton( - modifier = Modifier - .weight(1f) - .heightIn(min = 50.dp), - onClick = onUninstallClick, - shape = ShapeDefaults.Large - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.action_uninstall) - ) - } - - FilledTonalIconButton( - modifier = Modifier - .weight(1f) - .heightIn(min = 50.dp), - onClick = onLaunchClick, - shape = ShapeDefaults.Large - ) { - Icon( - painter = painterResource(R.drawable.ic_launch), - contentDescription = stringResource(R.string.action_launch) - ) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutScreen.kt index 567fdbbc..7d84cd3b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/about/AboutScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -47,6 +46,7 @@ class AboutScreen : Screen { } ) { paddingValues -> LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, contentPadding = paddingValues .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top), modifier = Modifier @@ -68,7 +68,7 @@ class AboutScreen : Screen { style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier - .padding(start = 16.dp, top = 12.dp, bottom = 16.dp) + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp) ) } @@ -101,61 +101,6 @@ class AboutScreen : Screen { } } -@Composable -private fun ProjectHeader(modifier: Modifier = Modifier) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - ) { - AsyncImage( - model = "https://github.com/Aliucord.png", - contentDescription = stringResource(R.string.aliucord), - modifier = Modifier.size(71.dp) - ) - - Text( - text = stringResource(R.string.aliucord), - style = MaterialTheme.typography.titleMedium.copy( - fontSize = 26.sp - ) - ) - - Text( - text = stringResource(R.string.app_description), - style = MaterialTheme.typography.titleSmall.copy( - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - ) - - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - val uriHandler = LocalUriHandler.current - - TextButton(onClick = { uriHandler.openUri("https://github.com/Aliucord") }) { - Icon( - painter = painterResource(R.drawable.ic_account_github_white_24dp), - contentDescription = stringResource(R.string.github) - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(text = stringResource(id = R.string.github)) - } - - TextButton(onClick = { uriHandler.openUri("https://aliucord.com") }) { - Icon( - painter = painterResource(R.drawable.ic_link), - contentDescription = stringResource(R.string.website) - ) - Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) - Text(text = stringResource(id = R.string.website)) - } - } - } -} - @Composable private fun MainContributors(modifier: Modifier = Modifier) { Row( 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 003a4283..a3f1827f 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 @@ -1,110 +1,125 @@ package com.aliucord.manager.ui.screens.home import android.app.Application +import android.content.Intent import android.content.pm.PackageManager +import android.provider.Settings import android.util.Log import androidx.compose.runtime.* +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +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 com.aliucord.manager.BuildConfig import com.aliucord.manager.R import com.aliucord.manager.domain.repository.GithubRepository -import com.aliucord.manager.installer.util.uninstallApk -import com.aliucord.manager.manager.PreferencesManager import com.aliucord.manager.network.utils.fold import com.aliucord.manager.ui.util.DiscordVersion -import com.aliucord.manager.util.getPackageVersion +import com.aliucord.manager.util.launchBlock import com.aliucord.manager.util.showToast -import kotlinx.coroutines.* +import kotlinx.collections.immutable.toImmutableList class HomeModel( private val application: Application, private val github: GithubRepository, - val preferences: PreferencesManager, ) : ScreenModel { var supportedVersion by mutableStateOf(DiscordVersion.None) private set - var installedVersion by mutableStateOf(DiscordVersion.None) + var installations by mutableStateOf(InstallsState.Fetching) private set init { - screenModelScope.launch(Dispatchers.IO) { - _fetchInstalledVersion() - _fetchSupportedVersion() - } + fetchInstallations() + fetchSupportedVersion() } - private suspend fun _fetchInstalledVersion() { - try { - val (versionName, versionCode) = application.getPackageVersion(preferences.packageName) - - withContext(Dispatchers.Main) { - installedVersion = DiscordVersion.Existing( - type = DiscordVersion.parseVersionType(versionCode), - name = versionName.split("-")[0].trim(), - code = versionCode, - ) - } - } catch (t: PackageManager.NameNotFoundException) { - withContext(Dispatchers.Main) { - installedVersion = DiscordVersion.None - } - } catch (t: Throwable) { - Log.e(BuildConfig.TAG, Log.getStackTraceString(t)) + fun launchApp(packageName: String) { + val launchIntent = application.packageManager + .getLaunchIntentForPackage(packageName) - withContext(Dispatchers.Main) { - installedVersion = DiscordVersion.Error - } + if (launchIntent != null) { + application.startActivity(launchIntent) + } else { + application.showToast(R.string.launch_aliucord_fail) } } - private suspend fun _fetchSupportedVersion() { - val version = github.getDataJson() + fun openAppInfo(packageName: String) { + val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setData("package:$packageName".toUri()) - withContext(Dispatchers.Main) { - version.fold( - success = { - val versionCode = it.versionCode.toIntOrNull() ?: return@fold + application.startActivity(launchIntent) + } - supportedVersion = DiscordVersion.Existing( - type = DiscordVersion.parseVersionType(versionCode), - name = it.versionName.split("-")[0].trim(), - code = versionCode, - ) - }, - fail = { - Log.e(BuildConfig.TAG, Log.getStackTraceString(it)) - supportedVersion = DiscordVersion.Error + private fun fetchInstallations() = screenModelScope.launchBlock { + try { + val packageManager = application.packageManager + + val installedPackages = packageManager + .getInstalledPackages(PackageManager.GET_META_DATA) + .takeIf { it.isNotEmpty() } + ?: throw IllegalStateException("Failed to fetch installed packages (returned none)") + + val aliucordPackages = installedPackages + .asSequence() + .filter { + val isAliucordPkg = it.packageName == "com.aliucord" + val hasAliucordMeta = it.applicationInfo.metaData?.containsKey("isAliucord") == true + isAliucordPkg || hasAliucordMeta } - ) - } - } - fun fetchInstalledVersion() { - screenModelScope.launch(Dispatchers.IO) { - _fetchInstalledVersion() - } - } + val aliucordInstallations = aliucordPackages + .map { + // `longVersionCode` is unnecessary since Discord doesn't use `versionCodeMajor` + @Suppress("DEPRECATION") + val versionCode = it.versionCode - fun fetchSupportedVersion() { - screenModelScope.launch(Dispatchers.IO) { - _fetchSupportedVersion() - } - } + val baseVersion = it.applicationInfo.metaData?.getInt("aliucordBaseVersion") + val isBaseUpdated = /* TODO: remote data json instead */ baseVersion == 0 - fun launchAliucord() { - val launchIntent = application.packageManager - .getLaunchIntentForPackage(preferences.packageName) + InstallData( + name = packageManager.getApplicationLabel(it.applicationInfo).toString(), + packageName = it.packageName, + baseUpdated = isBaseUpdated, + icon = packageManager + .getApplicationIcon(it.applicationInfo) + .toBitmap() + .asImageBitmap() + .let(::BitmapPainter), + version = DiscordVersion.Existing( + type = DiscordVersion.parseVersionType(versionCode), + name = it.versionName.split("-")[0].trim(), + code = versionCode, + ), + ) + } - if (launchIntent != null) { - application.startActivity(launchIntent) - } else { - application.showToast(R.string.launch_aliucord_fail) + installations = InstallsState.Fetched(data = aliucordInstallations.toImmutableList()) + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to query Aliucord installations", t) + installations = InstallsState.Error } } - fun uninstallAliucord() { - application.uninstallApk(preferences.packageName) + private fun fetchSupportedVersion() = screenModelScope.launchBlock { + github.getDataJson().fold( + success = { + val versionCode = it.versionCode.toIntOrNull() ?: return@fold + + supportedVersion = DiscordVersion.Existing( + type = DiscordVersion.parseVersionType(versionCode), + name = it.versionName.split("-")[0].trim(), + code = versionCode, + ) + }, + fail = { + Log.e(BuildConfig.TAG, Log.getStackTraceString(it)) + supportedVersion = DiscordVersion.Error + } + ) } } 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 eca80bc9..d0142a10 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 @@ -6,28 +6,33 @@ package com.aliucord.manager.ui.screens.home +import androidx.compose.animation.* import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.koin.getScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow -import com.aliucord.manager.BuildConfig import com.aliucord.manager.R +import com.aliucord.manager.ui.components.ProjectHeader import com.aliucord.manager.ui.components.dialogs.InstallerDialog -import com.aliucord.manager.ui.components.home.InfoCard -import com.aliucord.manager.ui.screens.about.AboutScreen +import com.aliucord.manager.ui.screens.home.components.* import com.aliucord.manager.ui.screens.install.InstallScreen import com.aliucord.manager.ui.screens.plugins.PluginsScreen -import com.aliucord.manager.ui.screens.settings.SettingsScreen +import com.aliucord.manager.ui.util.DiscordVersion +import com.aliucord.manager.ui.util.paddings.PaddingValuesSides +import com.aliucord.manager.ui.util.paddings.exclude class HomeScreen : Screen { override val key = "Home" @@ -38,11 +43,6 @@ class HomeScreen : Screen { val model = getScreenModel() var showInstallerDialog by remember { mutableStateOf(false) } - - LaunchedEffect(model.preferences.packageName) { - model.fetchInstalledVersion() - } - if (showInstallerDialog) { InstallerDialog( onDismiss = { showInstallerDialog = false }, @@ -56,66 +56,72 @@ class HomeScreen : Screen { Scaffold( topBar = { HomeAppBar() }, ) { paddingValues -> - Column( + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = paddingValues + .exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top), modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(paddingValues.exclude(PaddingValuesSides.Bottom)) + .padding(top = 16.dp, start = 16.dp, end = 16.dp), ) { - InfoCard( - packageName = model.preferences.packageName, - supportedVersion = model.supportedVersion, - currentVersion = model.installedVersion, - onDownloadClick = { showInstallerDialog = true }, - onLaunchClick = model::launchAliucord, - onUninstallClick = model::uninstallAliucord - ) - } - } - } -} - -@Composable -private fun HomeAppBar() { - TopAppBar( - title = { Text(stringResource(R.string.navigation_home)) }, - actions = { - val uriHandler = LocalUriHandler.current - val navigator = LocalNavigator.currentOrThrow + item(key = "PROJECT_HEADER") { + ProjectHeader() + } - IconButton( - onClick = { - uriHandler.openUri("https://discord.gg/${BuildConfig.SUPPORT_SERVER}") + item(key = "ADD_INSTALL_BUTTON") { + InstallButton( + // TODO: install options screen to configure pkg name + enabled = (model.installations as? InstallsState.Fetched)?.data?.isEmpty() ?: false, + onClick = { showInstallerDialog = true }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) } - ) { - Icon( - painter = painterResource(R.drawable.ic_discord), - contentDescription = stringResource(R.string.support_server) - ) - } - IconButton(onClick = { navigator.push(AboutScreen()) }) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(R.string.navigation_about) - ) - } + item(key = "SUPPORTED_VERSION") { + AnimatedVisibility( + enter = fadeIn() + slideInVertically { it * -2 }, + exit = fadeOut() + slideOutVertically { it * -2 }, + visible = model.supportedVersion !is DiscordVersion.None, + ) { + VersionDisplay( + version = model.supportedVersion, + prefix = { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(R.string.version_supported)) + append(" ") + } + }, + modifier = Modifier + .alpha(.5f) + .padding(bottom = 22.dp), + ) + } + } - IconButton(onClick = { navigator.push(PluginsScreen()) }) { - Icon( - painter = painterResource(R.drawable.ic_extension), - contentDescription = stringResource(R.string.navigation_about) - ) - } + val installations = (model.installations as? InstallsState.Fetched)?.data + ?: return@LazyColumn - IconButton(onClick = { navigator.push(SettingsScreen()) }) { - Icon( - painter = painterResource(R.drawable.ic_settings), - contentDescription = stringResource(R.string.navigation_settings) - ) + items(installations, key = { it.packageName }) { + AnimatedVisibility( + enter = fadeIn() + slideInHorizontally { it * -2 }, + exit = fadeOut() + slideOutHorizontally { it * 2 }, + visible = model.supportedVersion !is DiscordVersion.None, + ) { + InstalledItemCard( + data = it, + onUpdate = { showInstallerDialog = true }, // TODO: prefilled install options screen + onOpenApp = { model.launchApp(it.packageName) }, + onOpenInfo = { model.openAppInfo(it.packageName) }, + onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins + modifier = Modifier.fillMaxWidth() + ) + } + } } } - ) + } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/InstallData.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/InstallData.kt new file mode 100644 index 00000000..2fd30896 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/InstallData.kt @@ -0,0 +1,14 @@ +package com.aliucord.manager.ui.screens.home + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.painter.BitmapPainter +import com.aliucord.manager.ui.util.DiscordVersion + +@Immutable +data class InstallData( + val name: String, + val packageName: String, + val version: DiscordVersion, + val icon: BitmapPainter, + val baseUpdated: Boolean, +) diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/InstallsState.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/InstallsState.kt new file mode 100644 index 00000000..3441f3d7 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/InstallsState.kt @@ -0,0 +1,12 @@ +package com.aliucord.manager.ui.screens.home + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface InstallsState { + data object None : InstallsState + data object Error : InstallsState + data object Fetching : InstallsState + data class Fetched(val data: ImmutableList) : InstallsState +} 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 new file mode 100644 index 00000000..e5f9b00b --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/HomeAppBar.kt @@ -0,0 +1,37 @@ +package com.aliucord.manager.ui.screens.home.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.* +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 + +@Composable +fun HomeAppBar() { + TopAppBar( + title = {}, + actions = { + val navigator = LocalNavigator.currentOrThrow + + IconButton(onClick = { navigator.push(AboutScreen()) }) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = stringResource(R.string.navigation_about) + ) + } + + IconButton(onClick = { navigator.push(SettingsScreen()) }) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = stringResource(R.string.navigation_settings) + ) + } + } + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstallButton.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstallButton.kt new file mode 100644 index 00000000..eb8ed1c9 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstallButton.kt @@ -0,0 +1,40 @@ +package com.aliucord.manager.ui.screens.home.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.aliucord.manager.R + +@Composable +fun InstallButton( + enabled: Boolean = true, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FilledTonalIconButton( + shape = MaterialTheme.shapes.medium, + enabled = enabled, + onClick = onClick, + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = null, + ) + Text( + text = stringResource(R.string.action_add_install), + style = MaterialTheme.typography.labelLarge, + ) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstalledItemCard.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstalledItemCard.kt new file mode 100644 index 00000000..417a2b5e --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/InstalledItemCard.kt @@ -0,0 +1,158 @@ +package com.aliucord.manager.ui.screens.home.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.aliucord.manager.R +import com.aliucord.manager.ui.components.SegmentedButton +import com.aliucord.manager.ui.screens.home.InstallData + +@Composable +fun InstalledItemCard( + data: InstallData, + onUpdate: () -> Unit, + onOpenApp: () -> Unit, + onOpenInfo: () -> Unit, + onOpenPlugins: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard( + shape = MaterialTheme.shapes.medium, + elevation = CardDefaults.elevatedCardElevation( + defaultElevation = 3.dp, + ), + modifier = modifier + .width(IntrinsicSize.Max) + .shadow( + clip = false, + elevation = 2.dp, + shape = MaterialTheme.shapes.medium, + ) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.padding(20.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Image( + painter = data.icon, + contentDescription = null, + modifier = Modifier + .size(34.dp) + .clip(CircleShape), + ) + + Column { + Text( + text = "\"${data.name}\"", + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = .94f), + ) + + Text( + text = data.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(start = 1.dp) + .offset(y = (-2).dp) + .alpha(.7f) + .basicMarquee(), + ) + } + + Spacer(Modifier.weight(1f, fill = true)) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.End, + modifier = Modifier + .alpha(.6f) + .padding(end = 4.dp), + ) { + VersionDisplay( + version = data.version, + prefix = { append("v") }, + ) + + // TODO: display install core commit version + // Text( + // text = data.commit, + // style = MaterialTheme.typography.labelLarge, + // ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.clip(MaterialTheme.shapes.large), + ) { + SegmentedButton( + icon = painterResource(R.drawable.ic_extension), + text = stringResource(R.string.plugins_title), + onClick = onOpenPlugins, + ) + SegmentedButton( + icon = painterResource(R.drawable.ic_info), + text = stringResource(R.string.action_open_info), + onClick = onOpenInfo, + ) + + if (data.baseUpdated) { + SegmentedButton( + icon = painterResource(R.drawable.ic_launch), + text = stringResource(R.string.action_launch), + onClick = onOpenApp, + ) + } else { + val warningColor = Color(0xFFFFBB33) + + SegmentedButton( + icon = painterResource(R.drawable.ic_update), + text = stringResource(R.string.action_update), + iconColor = warningColor, + textColor = warningColor, + onClick = onUpdate, + ) + } + } + } + } +} + +@Composable +private fun LabelTextItem( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("• ") + append(label) + } + append(" ") + append(value) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = .9f), + modifier = modifier, + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/VersionDisplay.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/VersionDisplay.kt new file mode 100644 index 00000000..b532f08f --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/home/components/VersionDisplay.kt @@ -0,0 +1,30 @@ +package com.aliucord.manager.ui.screens.home.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.* +import com.aliucord.manager.ui.util.DiscordVersion + +@Composable +fun VersionDisplay( + version: DiscordVersion, + prefix: (@Composable AnnotatedString.Builder.() -> Unit)? = null, + style: TextStyle = MaterialTheme.typography.labelLarge, + modifier: Modifier = Modifier, +) { + Text( + text = buildAnnotatedString { + prefix?.invoke(this) + + if (version is DiscordVersion.Existing) { + append(version.name) + append(" - ") + } + append(version.toDisplayName()) + }, + style = style, + modifier = modifier, + ) +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/util/DiscordVersion.kt b/app/src/main/kotlin/com/aliucord/manager/ui/util/DiscordVersion.kt index 4b0ac5f0..51d6f02d 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/util/DiscordVersion.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/util/DiscordVersion.kt @@ -1,12 +1,14 @@ package com.aliucord.manager.ui.util import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource import com.aliucord.manager.R +@Immutable sealed interface DiscordVersion : Comparable { - object Error : DiscordVersion - object None : DiscordVersion + data object Error : DiscordVersion + data object None : DiscordVersion data class Existing( val type: Type, diff --git a/app/src/main/kotlin/com/aliucord/manager/util/Coroutines.kt b/app/src/main/kotlin/com/aliucord/manager/util/Coroutines.kt new file mode 100644 index 00000000..d7d9092b --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/util/Coroutines.kt @@ -0,0 +1,22 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package com.aliucord.manager.util + +import kotlinx.coroutines.* +import kotlin.coroutines.CoroutineContext + +/** + * Launch a Job for a block without returning anything + */ +inline fun CoroutineScope.launchBlock( + context: CoroutineContext = Dispatchers.Main, + noinline block: suspend CoroutineScope.() -> Unit, +) { + launch(context, block = block) +} + +/** + * Wrapper util to run a block with the main thread context + */ +suspend inline fun mainThread(noinline block: CoroutineScope.() -> Unit) = + withContext(Dispatchers.Main, block) diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 00000000..bf12e7ff --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_aliucord_logo.xml b/app/src/main/res/drawable/ic_aliucord_logo.xml new file mode 100644 index 00000000..ce5b8adf --- /dev/null +++ b/app/src/main/res/drawable/ic_aliucord_logo.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 00000000..aa33ecba --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7fdb02c6..8f26dd71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ A modification for the Discord Android App Aliucord + Discord GitHub Support Server Commits @@ -15,6 +16,7 @@ Confirm Dismiss Install + Add Installation Reinstall Update Clear @@ -29,6 +31,7 @@ Copied! Cleared cache! Exit anyways + Open info Grant Permissions In order for Aliucord Manager to function, file permissions are required. Since shared data is stored in ~/Aliucord, permissions are required in order to access it. @@ -78,6 +81,9 @@ Failed to load + Package: + Discord version: + Supported version: Installed version: None