feat: Revamp UI and improve UX

Co-authored-by: Ushie <github@ushie.dev>
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
Co-authored-by: Robert <72943079+cnc-robert@users.noreply.github.com>
Co-authored-by: rushii <33725716+rushiimachine@users.noreply.github.com>
Co-authored-by: Ax333l <main@axelen.xyz>
This commit is contained in:
Plance Shimney
2026-03-14 11:38:07 +01:00
committed by oSumAtrIX
parent 5eea987b82
commit 2d42197012
119 changed files with 7810 additions and 2381 deletions

View File

@@ -30,10 +30,16 @@ kotlin {
android {
namespace = "app.revanced.manager.downloader"
compileSdk = 36
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
minSdk = 26
minSdk {
version = release(26)
}
consumerProguardFiles("consumer-rules.pro")
}
@@ -41,6 +47,7 @@ android {
buildTypes {
release {
isMinifyEnabled = false
// Proguard optimisation is disabled by -dontoptimize in the file
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"

0
api/consumer-rules.pro Normal file
View File

View File

@@ -64,7 +64,7 @@ dependencies {
implementation(libs.revanced.patcher)
implementation(libs.revanced.library)
// Downloader plugins
// Downloaders
implementation(project(":api"))
// Native processes
@@ -131,13 +131,20 @@ buildscript {
android {
namespace = "app.revanced.manager"
compileSdk = 36
buildToolsVersion = "35.0.1"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "app.revanced.manager.flutter"
minSdk = 26
targetSdk = 35
minSdk {
version = release(26)
}
targetSdk {
version = release(36)
}
val versionStr = if (version == "unspecified") "1.0.0" else version.toString()
versionName = versionStr
@@ -161,20 +168,23 @@ android {
.joinToString(prefix = "{", separator = ",", postfix = "}") { "\"$it\"" }
buildConfigField("String[]", "SUPPORTED_LOCALES", locales)
val deepLinkScheme = "revanced-manager"
manifestPlaceholders["deepLinkScheme"] = deepLinkScheme
buildConfigField("String", "DEEP_LINK_SCHEME", "\"$deepLinkScheme\"")
}
buildTypes {
debug {
resValue("string", "app_name", "ReVanced Manager (Debug)")
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
}
release {
if (!project.hasProperty("noProguard")) {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
val keystoreFile = file("keystore.jks")
@@ -192,8 +202,6 @@ android {
keyPassword = System.getenv("KEYSTORE_ENTRY_PASSWORD")
}
}
buildConfigField("long", "BUILD_ID", "0L")
}
}
@@ -266,7 +274,6 @@ android {
}
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.apply {

View File

@@ -1,4 +1,5 @@
-dontobfuscate
-dontoptimize
-keepattributes *
-keep class app.revanced.manager.patcher.runtime.process.* { *; }

View File

@@ -41,12 +41,21 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="layoutDirection|locale"
android:theme="@style/Theme.ReVancedManager">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${deepLinkScheme}" />
</intent-filter>
</activity>
<activity android:name=".DownloaderActivity" android:exported="false" android:theme="@style/Theme.DownloaderActivity" />
@@ -82,5 +91,15 @@
android:name="ru.solrudev.ackpine.AckpineInitializer"
tools:node="remove" />
</provider>
<provider
android:name=".ManagerFileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>

View File

@@ -5,8 +5,6 @@ import app.revanced.manager.patcher.runtime.process.Parameters;
import app.revanced.manager.patcher.runtime.process.IPatcherEvents;
interface IPatcherProcess {
// Returns BuildConfig.BUILD_ID, which is used to ensure the main app and runner process are running the same code.
long buildId();
// Makes the patcher process exit with code 0
oneway void exit();
// Starts patching.

View File

@@ -6,14 +6,19 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.EaseInOutQuad
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
@@ -25,9 +30,11 @@ import androidx.navigation.toRoute
import app.revanced.manager.ui.model.navigation.Announcement
import app.revanced.manager.ui.model.navigation.Announcements
import app.revanced.manager.ui.model.navigation.AppSelector
import app.revanced.manager.ui.model.navigation.BundleInformation
import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard
import app.revanced.manager.ui.model.navigation.InstalledApplicationInfo
import app.revanced.manager.ui.model.navigation.Onboarding
import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.Settings
@@ -35,8 +42,10 @@ import app.revanced.manager.ui.model.navigation.Update
import app.revanced.manager.ui.screen.AnnouncementScreen
import app.revanced.manager.ui.screen.AnnouncementsScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.BundleInformationScreen
import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
import app.revanced.manager.ui.screen.OnboardingScreen
import app.revanced.manager.ui.screen.PatcherScreen
import app.revanced.manager.ui.screen.PatchesSelectorScreen
import app.revanced.manager.ui.screen.RequiredOptionsScreen
@@ -47,6 +56,7 @@ import app.revanced.manager.ui.screen.settings.AboutSettingsScreen
import app.revanced.manager.ui.screen.settings.AdvancedSettingsScreen
import app.revanced.manager.ui.screen.settings.ContributorSettingsScreen
import app.revanced.manager.ui.screen.settings.DeveloperSettingsScreen
import app.revanced.manager.ui.screen.settings.DownloaderInfoScreen
import app.revanced.manager.ui.screen.settings.DownloadsSettingsScreen
import app.revanced.manager.ui.screen.settings.GeneralSettingsScreen
import app.revanced.manager.ui.screen.settings.ImportExportSettingsScreen
@@ -58,6 +68,10 @@ import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.MainViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.deepLinkedComposable
import app.revanced.manager.util.navigateSafe
import app.revanced.manager.util.popBackStackSafe
import app.revanced.manager.util.resetListItemColorsCached
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -80,6 +94,10 @@ class MainActivity : AppCompatActivity() {
val dynamicColor by vm.prefs.dynamicColor.getAsState()
val pureBlackTheme by vm.prefs.pureBlackTheme.getAsState()
LaunchedEffect(theme, dynamicColor, pureBlackTheme) {
resetListItemColorsCached()
}
ReVancedManagerTheme(
darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
dynamicColor = dynamicColor,
@@ -94,6 +112,11 @@ class MainActivity : AppCompatActivity() {
@Composable
private fun ReVancedManager(vm: MainViewModel) {
val navController = rememberNavController()
val completedOnboarding by vm.prefs.completedOnboarding.getAsState()
// please dont unmemoize this bahahahah
val startDestination = remember {
if (completedOnboarding) Dashboard else Onboarding
}
EventEffect(vm.appSelectFlow) { app ->
navController.navigateComplex(
@@ -104,26 +127,40 @@ private fun ReVancedManager(vm: MainViewModel) {
NavHost(
navController = navController,
startDestination = Dashboard,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
startDestination = startDestination,
enterTransition = { slideInHorizontally(animationSpec = tween(300, easing = EaseInOutQuad), initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(animationSpec = tween(250, easing = EaseOut), targetOffsetX = { -it / 3 }) },
popEnterTransition = { slideInHorizontally(animationSpec = tween(300, easing = EaseInOutQuad), initialOffsetX = { -it / 3 }) },
popExitTransition = { slideOutHorizontally(animationSpec = tween(250, easing = EaseOut), targetOffsetX = { it }) }
) {
composable<Onboarding> {
OnboardingScreen(
onFinish = {
navController.navigateSafe(route = Dashboard) {
popUpTo<Onboarding> { inclusive = true }
}
},
onAppClick = vm::selectApp,
)
}
composable<Dashboard> {
DashboardScreen(
onSettingsClick = { navController.navigate(Settings) },
onSettingsClick = { navController.navigateSafe(Settings) },
onAppSelectorClick = {
navController.navigate(AppSelector)
navController.navigateSafe(AppSelector)
},
onUpdateClick = {
navController.navigate(Update())
navController.navigateSafe(Update())
},
onDownloaderClick = {
navController.navigate(Settings.Downloads)
navController.navigateSafe(Settings.Downloads)
},
onAppClick = { packageName ->
navController.navigate(InstalledApplicationInfo(packageName))
navController.navigateSafe(InstalledApplicationInfo(packageName))
},
onBundleClick = { uid ->
navController.navigateSafe(BundleInformation(uid))
},
onAnnouncementsClick = {
navController.navigate(Announcements)
@@ -134,12 +171,19 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<BundleInformation> {
BundleInformationScreen(
onBackClick = navController::popBackStackSafe,
viewModel = koinViewModel()
)
}
composable<InstalledApplicationInfo> {
val data = it.toRoute<InstalledApplicationInfo>()
InstalledAppInfoScreen(
onPatchClick = vm::selectApp,
onBackClick = navController::popBackStack,
onBackClick = navController::popBackStackSafe,
viewModel = koinViewModel { parametersOf(data.packageName) }
)
}
@@ -148,14 +192,14 @@ private fun ReVancedManager(vm: MainViewModel) {
AppSelectorScreen(
onSelect = vm::selectApp,
onStorageSelect = vm::selectApp,
onBackClick = navController::popBackStack
onBackClick = navController::popBackStackSafe
)
}
composable<Patcher> {
PatcherScreen(
onBackClick = {
navController.navigate(route = Dashboard) {
navController.navigateSafe(route = Dashboard) {
launchSingleTop = true
popUpTo<Dashboard> {
inclusive = false
@@ -170,7 +214,7 @@ private fun ReVancedManager(vm: MainViewModel) {
val data = it.toRoute<Update>()
UpdateScreen(
onBackClick = navController::popBackStack,
onBackClick = navController::popBackStackSafe,
vm = koinViewModel { parametersOf(data.downloadOnScreenEntry) }
)
}
@@ -202,7 +246,7 @@ private fun ReVancedManager(vm: MainViewModel) {
}
SelectedAppInfoScreen(
onBackClick = navController::popBackStack,
onBackClick = navController::popBackStackSafe,
onPatchClick = {
it.lifecycleScope.launch {
navController.navigateComplex(
@@ -243,10 +287,10 @@ private fun ReVancedManager(vm: MainViewModel) {
)
PatchesSelectorScreen(
onBackClick = navController::popBackStack,
onBackClick = navController::popBackStackSafe,
onSave = { patches, options ->
selectedAppInfoVm.updateConfiguration(patches, options)
navController.popBackStack()
navController.popBackStackSafe()
},
viewModel = koinViewModel { parametersOf(data) }
)
@@ -260,7 +304,7 @@ private fun ReVancedManager(vm: MainViewModel) {
)
RequiredOptionsScreen(
onBackClick = navController::popBackStack,
onBackClick = navController::popBackStackSafe,
onContinue = { patches, options ->
selectedAppInfoVm.updateConfiguration(patches, options)
it.lifecycleScope.launch {
@@ -276,58 +320,71 @@ private fun ReVancedManager(vm: MainViewModel) {
}
navigation<Settings>(startDestination = Settings.Main) {
composable<Settings.Main> {
deepLinkedComposable<Settings.Main>("settings") {
SettingsScreen(
onBackClick = navController::popBackStack,
navigate = navController::navigate
onBackClick = navController::popBackStackSafe,
navigate = navController::navigateSafe
)
}
composable<Settings.General> {
GeneralSettingsScreen(onBackClick = navController::popBackStack)
deepLinkedComposable<Settings.General>("settings/general") {
GeneralSettingsScreen(onBackClick = navController::popBackStackSafe)
}
composable<Settings.Advanced> {
AdvancedSettingsScreen(onBackClick = navController::popBackStack)
deepLinkedComposable<Settings.Advanced>("settings/advanced") {
AdvancedSettingsScreen(onBackClick = navController::popBackStackSafe)
}
composable<Settings.Developer> {
DeveloperSettingsScreen(onBackClick = navController::popBackStack)
deepLinkedComposable<Settings.Developer>("settings/developer") {
DeveloperSettingsScreen(onBackClick = navController::popBackStackSafe)
}
composable<Settings.Updates> {
deepLinkedComposable<Settings.Updates>("settings/updates") {
UpdatesSettingsScreen(
onBackClick = navController::popBackStack,
onChangelogClick = { navController.navigate(Settings.Changelogs) },
onUpdateClick = { navController.navigate(Update()) }
onBackClick = navController::popBackStackSafe,
onChangelogClick = { navController.navigateSafe(Settings.Changelogs) },
onUpdateClick = { navController.navigateSafe(Update()) }
)
}
composable<Settings.Downloads> {
DownloadsSettingsScreen(onBackClick = navController::popBackStack)
deepLinkedComposable<Settings.Downloads>("settings/downloads") {
DownloadsSettingsScreen(
onBackClick = navController::popBackStackSafe,
onDownloaderClick = { packageName ->
navController.navigateSafe(Settings.DownloadersInfo(packageName))
}
)
}
composable<Settings.ImportExport> {
ImportExportSettingsScreen(onBackClick = navController::popBackStack)
composable<Settings.DownloadersInfo> {
val route = it.toRoute<Settings.DownloadersInfo>()
DownloaderInfoScreen(
packageName = route.packageName,
onBackClick = navController::popBackStackSafe
)
}
composable<Settings.About> {
deepLinkedComposable<Settings.ImportExport>("settings/import-export") {
ImportExportSettingsScreen(onBackClick = navController::popBackStackSafe)
}
deepLinkedComposable<Settings.About>("about") {
AboutSettingsScreen(
onBackClick = navController::popBackStack,
navigate = navController::navigate
onBackClick = navController::popBackStackSafe,
navigate = navController::navigateSafe
)
}
composable<Settings.Changelogs> {
ChangelogsSettingsScreen(onBackClick = navController::popBackStack)
ChangelogsSettingsScreen(onBackClick = navController::popBackStackSafe)
}
composable<Settings.Contributors> {
ContributorSettingsScreen(onBackClick = navController::popBackStack)
ContributorSettingsScreen(onBackClick = navController::popBackStackSafe)
}
composable<Settings.Licenses> {
LicensesSettingsScreen(onBackClick = navController::popBackStack)
LicensesSettingsScreen(onBackClick = navController::popBackStackSafe)
}
}
@@ -343,8 +400,10 @@ private fun <T : Parcelable, R : ComplexParameter<T>> NavController.navigateComp
route: R,
data: T
) {
navigate(route)
getBackStackEntry(route).savedStateHandle["args"] = data
if (currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED) {
navigate(route)
getBackStackEntry(route).savedStateHandle["args"] = data
}
}
private fun <T : Parcelable> NavBackStackEntry.getComplexArg() = savedStateHandle.get<T>("args")!!

View File

@@ -0,0 +1,5 @@
package app.revanced.manager
import androidx.core.content.FileProvider
class ManagerFileProvider : FileProvider()

View File

@@ -29,7 +29,7 @@ class Filesystem(private val app: Application) {
* This is the same as [tempDir], but does not get cleared on system-initiated process death.
* Paths to this directory can be safely stored in parcels.
*/
val uiTempDir: File = app.getDir("ui_ephemeral", Context.MODE_PRIVATE)
val uiTempDir: File = File(app.filesDir, "ui_ephemeral").apply { mkdirs() }
fun externalFilesDir(): Path = Environment.getExternalStorageDirectory().toPath()

View File

@@ -38,6 +38,12 @@ abstract class SelectionDao {
@Query("SELECT package_name FROM patch_selections")
abstract fun getPackagesWithSelection(): Flow<List<String>>
@Query("SELECT COUNT(DISTINCT package_name) FROM patch_selections")
abstract fun getSelectionPackageCount(): Flow<Int>
@Query("SELECT COUNT(*) FROM selected_patches")
abstract fun getSelectedPatchCount(): Flow<Int>
@Query("DELETE FROM patch_selections WHERE patch_bundle = :uid")
abstract suspend fun resetForPatchBundle(uid: Int)

View File

@@ -11,6 +11,7 @@ import org.koin.dsl.module
val repositoryModule = module {
singleOf(::ReVancedAPI)
singleOf(::ManagerUpdateRepository)
singleOf(::AnnouncementRepository)
singleOf(::Filesystem) {
createdAtStart()

View File

@@ -6,6 +6,7 @@ import org.koin.dsl.module
val viewModelModule = module {
viewModelOf(::MainViewModel)
viewModelOf(::OnboardingViewModel)
viewModelOf(::DashboardViewModel)
viewModelOf(::SelectedAppInfoViewModel)
viewModelOf(::PatchesSelectorViewModel)
@@ -25,4 +26,5 @@ val viewModelModule = module {
viewModelOf(::InstalledAppInfoViewModel)
viewModelOf(::UpdatesSettingsViewModel)
viewModelOf(::BundleListViewModel)
viewModelOf(::BundleInformationViewModel)
}

View File

@@ -20,9 +20,9 @@ class PreferencesManager(
val keystoreAlias = stringPreference("keystore_alias", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val completedOnboarding = booleanPreference("completed_onboarding", false)
val readAnnouncements = longSetPreference("read_announcements", emptySet())
val selectedAnnouncementTags = stringSetPreference("selected_announcement_tags", setOf("revanced", "manager"))
val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
val useManagerPrereleases = booleanPreference("manager_prereleases", false)
@@ -34,6 +34,9 @@ class PreferencesManager(
val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
val acknowledgedDownloaders = stringSetPreference("acknowledged_downloaders", emptySet())
val downloaderAutoUpdates = booleanPreference("downloader_auto_updates", true)
val apiDownloaderPackage = stringPreference("api_downloader_package", "")
val showDeveloperSettings = booleanPreference("show_developer_settings", context.isDebuggable)

View File

@@ -4,20 +4,27 @@ import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.pm.PackageInfo
import android.net.Uri
import android.os.Parcelable
import android.util.Log
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.downloader.TrustedDownloader
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.downloader.DownloaderPackageState
import app.revanced.manager.network.downloader.LoadedDownloader
import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.downloader.DownloaderBuilder
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.Scope
import app.revanced.manager.util.PM
import app.revanced.manager.util.tag
import dalvik.system.PathClassLoader
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.url
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,6 +32,12 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import ru.solrudev.ackpine.installer.InstallFailure
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.installer.createSession
import ru.solrudev.ackpine.session.Session
import ru.solrudev.ackpine.session.await
import ru.solrudev.ackpine.session.parameters.Confirmation
import java.io.File
import java.lang.reflect.Modifier
@@ -33,12 +46,26 @@ class DownloaderRepository(
private val pm: PM,
private val prefs: PreferencesManager,
private val app: Application,
private val reVancedAPI: ReVancedAPI,
private val http: HttpService,
private val ackpineInstaller: PackageInstaller,
db: AppDatabase
) {
sealed interface ApiDownloaderActionResult {
data class Success(val packageName: String) : ApiDownloaderActionResult
data object NoAsset : ApiDownloaderActionResult
data object NoInstalled : ApiDownloaderActionResult
data object NotTargetDownloader : ApiDownloaderActionResult
data object NoUpdate : ApiDownloaderActionResult
data object Aborted : ApiDownloaderActionResult
data object Failed : ApiDownloaderActionResult
}
private val trustDao = db.trustedDownloaderDao()
private val _downloaderPackageStates =
MutableStateFlow(emptyMap<String, DownloaderPackageState>())
val downloaderPackageStates = _downloaderPackageStates.asStateFlow()
val apiDownloaderPackageName = prefs.apiDownloaderPackage.flow.map { it.takeIf(String::isNotEmpty) }
val loadedDownloadersFlow = downloaderPackageStates.map { states ->
states.values.filterIsInstance<DownloaderPackageState.Loaded>().flatMap { it.downloaders }
}
@@ -72,9 +99,15 @@ class DownloaderRepository(
this@DownloaderRepository.acknowledgedPackageNames.update(acknowledgedDownloader subtract uninstalledDownloader)
trustDao.removeAll(uninstalledDownloader)
withContext(Dispatchers.IO) {
uninstalledDownloader.forEach { downloadersDir.resolve(it).deleteRecursively() }
}
val apiPkg = prefs.apiDownloaderPackage.get()
if (apiPkg in uninstalledDownloader) {
prefs.apiDownloaderPackage.update("")
}
}
}
@@ -179,6 +212,129 @@ class DownloaderRepository(
return pm.hasSignature(packageName, expectedSignature)
}
suspend fun getApiDownloaderAsset(): ReVancedAsset? = try {
reVancedAPI.getDownloaderAsset().getOrThrow()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(tag, "Failed to fetch API downloader info", e)
null
}
suspend fun getInstalledApiDownloader(): Pair<String, String>? {
val packageName = prefs.apiDownloaderPackage.get().takeIf { it.isNotEmpty() } ?: return null
val version = pm.getPackageInfo(packageName)?.versionName ?: return null
return packageName to version
}
suspend fun checkApiDownloaderUpdate(): ReVancedAsset? {
val asset = getApiDownloaderAsset() ?: return null
val installed = getInstalledApiDownloader()
if (installed != null) {
val installedVersion = installed.second.removePrefix("v")
val remoteVersion = asset.version.removePrefix("v")
if (installedVersion == remoteVersion) return null
}
return asset
}
suspend fun installLatestApiDownloader(
onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> },
onInstalling: ((Boolean) -> Unit)? = null,
): ApiDownloaderActionResult {
val asset = getApiDownloaderAsset() ?: return ApiDownloaderActionResult.NoAsset
return performApiDownloaderInstall(asset, onProgress, onInstalling)
}
suspend fun updateInstalledApiDownloader(
packageName: String? = null,
onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> },
onInstalling: ((Boolean) -> Unit)? = null,
): ApiDownloaderActionResult {
val installed = getInstalledApiDownloader() ?: return ApiDownloaderActionResult.NoInstalled
if (packageName != null && installed.first != packageName) {
return ApiDownloaderActionResult.NotTargetDownloader
}
val asset = checkApiDownloaderUpdate() ?: return ApiDownloaderActionResult.NoUpdate
return performApiDownloaderInstall(asset, onProgress, onInstalling)
}
suspend fun installApiDownloaderAsset(
asset: ReVancedAsset,
onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> },
onInstalling: ((Boolean) -> Unit)? = null,
): ApiDownloaderActionResult = performApiDownloaderInstall(asset, onProgress, onInstalling)
private suspend fun performApiDownloaderInstall(
asset: ReVancedAsset,
onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> },
onInstalling: ((Boolean) -> Unit)? = null,
): ApiDownloaderActionResult = withContext(Dispatchers.IO) {
getInstalledApiDownloader()?.let { (packageName, installedVersion) ->
if (installedVersion.removePrefix("v") == asset.version.removePrefix("v")) {
Log.i(tag, "API downloader $packageName is already up to date (${asset.version})")
return@withContext ApiDownloaderActionResult.Success(packageName)
}
}
val tempFile = File.createTempFile("api_downloader", ".apk", app.cacheDir)
try {
http.download(tempFile) {
url(asset.downloadUrl)
onDownload { bytesSentTotal, contentLength ->
onProgress(bytesSentTotal, contentLength)
}
}
val apkSignature = pm.getApkSignature(tempFile)
val managerSignature = pm.getManagerSignature()
val signatureMatches = apkSignature != null && apkSignature == managerSignature
val pkgInfo = pm.getPackageInfo(tempFile)
?: throw Exception("Could not parse downloaded APK")
val packageName = pkgInfo.packageName
onInstalling?.invoke(true)
val result = ackpineInstaller.createSession(Uri.fromFile(tempFile)) {
confirmation = Confirmation.IMMEDIATE
}.await()
when (result) {
is Session.State.Failed<*> -> {
val failure = result.failure as? InstallFailure
if (failure is InstallFailure.Aborted) {
return@withContext ApiDownloaderActionResult.Aborted
}
Log.e(tag, "Failed to install API downloader: ${result.failure}")
return@withContext ApiDownloaderActionResult.Failed
}
Session.State.Succeeded -> {
Log.i(tag, "Successfully installed API downloader: $packageName")
}
}
onInstalling?.invoke(false)
prefs.apiDownloaderPackage.update(packageName)
if (signatureMatches) {
trustPackage(packageName)
} else {
reload()
}
ApiDownloaderActionResult.Success(packageName)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(tag, "Failed to download/install API downloader", e)
ApiDownloaderActionResult.Failed
} finally {
tempFile.delete()
}
}
private companion object {
const val DOWNLOADER_FEATURE = "app.revanced.manager.downloader"
const val CLASSES_RESOURCE_NAME = "app.revanced.manager.downloader.classes"

View File

@@ -0,0 +1,22 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.network.api.ReVancedAPI
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class ManagerUpdateRepository(
private val reVancedAPI: ReVancedAPI
) {
private val _availableVersion = MutableStateFlow<String?>(null)
val availableVersion = _availableVersion.asStateFlow()
suspend fun refreshAvailableVersion(): String? {
val version = reVancedAPI.getAppUpdate()?.version
_availableVersion.value = version
return version
}
fun clearAvailableVersion() {
_availableVersion.value = null
}
}

View File

@@ -30,6 +30,10 @@ class PatchSelectionRepository(db: AppDatabase) {
fun getPackagesWithSavedSelection() =
dao.getPackagesWithSelection().map(Iterable<String>::toSet).distinctUntilChanged()
fun getSelectionPackageCount() = dao.getSelectionPackageCount().distinctUntilChanged()
fun getSelectedPatchCount() = dao.getSelectedPatchCount().distinctUntilChanged()
suspend fun resetSelectionForPackage(packageName: String) {
dao.resetForPackage(packageName)
}

View File

@@ -19,17 +19,18 @@ class ReVancedAPI(
private val prefs: PreferencesManager
) {
private suspend fun apiUrl() = prefs.api.get()
private val apiVersion = "v5"
private suspend inline fun <reified T> request(api: String, route: String): APIResponse<T> =
private suspend inline fun <reified T> request(api: String, route: String, apiVersion: String = this.apiVersion): APIResponse<T> =
withContext(
Dispatchers.IO
) {
client.request {
url("$api/v5/$route")
url("$api/$apiVersion/$route")
}
}
private suspend inline fun <reified T> request(route: String) = request<T>(apiUrl(), route)
private suspend inline fun <reified T> request(route: String, apiVersion: String = this.apiVersion) = request<T>(apiUrl(), route, apiVersion)
suspend fun getAnnouncements() = request<List<ReVancedAnnouncement>>("announcements")
@@ -41,9 +42,11 @@ class ReVancedAPI(
suspend fun getPatchesUpdate() = request<ReVancedAsset>("patches${prefs.usePatchesPrereleases.prereleaseString()}")
suspend fun getDownloaderAsset() = request<ReVancedAsset>("manager/downloaders${prefs.useManagerPrereleases.get()}")
suspend fun getContributors() = request<List<ReVancedGitRepository>>("contributors")
suspend fun getInfo(api: String? = null) = request<ReVancedInfo>(api ?: apiUrl(), "about")
suspend fun getInfo() = request<ReVancedInfo>("about")
private companion object {
suspend fun Preference<Boolean>.prereleaseString() = if (get()) "/prerelease" else ""

View File

@@ -1,13 +1,11 @@
package app.revanced.manager.network.dto
import android.os.Parcelable
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Clock
@Parcelize
@Serializable
@@ -18,10 +16,10 @@ data class ReVancedAnnouncement(
val content: String,
val tags: List<String>,
@SerialName("created_at")
val createdAt: LocalDateTime,
val createdAt: Instant,
@SerialName("archived_at")
val archivedAt: LocalDateTime?,
val archivedAt: Instant?,
val level: Int,
) : Parcelable {
val isArchived get() = archivedAt?.toInstant(TimeZone.UTC)?.let { it < Clock.System.now() } ?: false
val isArchived get() = archivedAt?.let { it < Clock.System.now() } ?: false
}

View File

@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import app.revanced.manager.BuildConfig
@@ -116,9 +117,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
val binder = awaitBinderConnection()
// Android Studio's fast deployment feature causes an issue where the other process will be running older code compared to the main process.
// The patcher process is running outdated code if the randomly generated BUILD_ID numbers don't match.
// The patcher process might be running outdated code if the fast deployment feature is used.
// To fix it, clear the cache in the Android settings or disable fast deployment (Run configurations -> Edit Configurations -> app -> Enable "always deploy with package manager").
if (binder.buildId() != BuildConfig.BUILD_ID) throw Exception("app_process is running outdated code. Clear the app cache or disable disable Android 11 deployment optimizations in your IDE")
if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
logger.warn("External patcher process: app_process could be running outdated code. To resolve stale code, clear the app cache or disable Android 11 deployment optimizations in your IDE.")
}
val eventHandler = object : IPatcherEvents.Stub() {
override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg)

View File

@@ -45,7 +45,6 @@ class PatcherProcess() : IPatcherProcess.Stub() {
exitProcess(1)
})
override fun buildId() = BuildConfig.BUILD_ID
override fun exit() = exitProcess(0)
override fun start(parameters: Parameters, events: IPatcherEvents) {

View File

@@ -5,8 +5,10 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -42,7 +44,7 @@ fun AppScaffold(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppTopBar(
title: String,
@@ -69,7 +71,7 @@ fun AppTopBar(
scrollBehavior = scrollBehavior,
navigationIcon = {
if (onBackClick != null) {
IconButton(onClick = onBackClick) {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
backIcon()
}
}
@@ -81,7 +83,7 @@ fun AppTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppTopBar(
title: @Composable () -> Unit,
@@ -108,7 +110,7 @@ fun AppTopBar(
scrollBehavior = scrollBehavior,
navigationIcon = {
if (onBackClick != null) {
IconButton(onClick = onBackClick) {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
backIcon()
}
}

View File

@@ -3,8 +3,10 @@ package app.revanced.manager.ui.component
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -12,6 +14,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ArrowButton(
modifier: Modifier = Modifier,
@@ -27,7 +30,7 @@ fun ArrowButton(
)
onClick?.let {
IconButton(onClick = it) {
IconButton(onClick = it, shapes = IconButtonDefaults.shapes()) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(description),

View File

@@ -1,83 +0,0 @@
package app.revanced.manager.ui.component
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.util.transparentListItemColors
@Composable
fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
var patchesEnabled by rememberSaveable { mutableStateOf(true) }
var managerEnabled by rememberSaveable { mutableStateOf(true) }
AlertDialog(
onDismissRequest = {},
confirmButton = {
TextButton(onClick = { onSubmit(managerEnabled, patchesEnabled) }) {
Text(stringResource(R.string.save))
}
},
icon = { Icon(Icons.Outlined.Update, null) },
title = { Text(text = stringResource(R.string.auto_updates_dialog_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = stringResource(R.string.auto_updates_dialog_description))
Column {
AutoUpdatesItem(
headline = R.string.auto_updates_dialog_manager,
icon = Icons.Outlined.Update,
checked = managerEnabled,
onCheckedChange = { managerEnabled = it }
)
HorizontalDivider()
AutoUpdatesItem(
headline = R.string.auto_updates_dialog_patches,
icon = Icons.Outlined.Source,
checked = patchesEnabled,
onCheckedChange = { patchesEnabled = it }
)
}
Text(text = stringResource(R.string.auto_updates_dialog_note))
}
}
)
}
@Composable
private fun AutoUpdatesItem(
@StringRes headline: Int,
icon: ImageVector,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) = ListItem(
leadingContent = { Icon(icon, null) },
headlineContent = { Text(stringResource(headline)) },
trailingContent = { HapticCheckbox(checked = checked, onCheckedChange = null) },
modifier = Modifier.clickable { onCheckedChange(!checked) },
colors = transparentListItemColors
)

View File

@@ -5,6 +5,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.*
@@ -18,6 +20,7 @@ import app.revanced.manager.R
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AvailableUpdateDialog(
onDismiss: () -> Unit,
@@ -38,14 +41,16 @@ fun AvailableUpdateDialog(
onClick = {
dismissDialog()
onConfirm()
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.show))
}
},
dismissButton = {
TextButton(
onClick = dismissDialog
onClick = dismissDialog,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.dismiss))
}
@@ -58,7 +63,9 @@ fun AvailableUpdateDialog(
},
text = {
Column(
modifier = Modifier.padding(horizontal = 8.dp),
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(

View File

@@ -0,0 +1,26 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun BottomContentBar(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp),
content: @Composable ColumnScope.() -> Unit
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content
)
}

View File

@@ -17,6 +17,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.core.view.HapticFeedbackConstantsCompat
import app.revanced.manager.util.withHapticFeedback
@Composable
fun CheckedFilterChip(
@@ -34,7 +36,7 @@ fun CheckedFilterChip(
) {
FilterChip(
selected = selected,
onClick = onClick,
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.CONFIRM),
label = label,
modifier = modifier,
enabled = enabled,

View File

@@ -0,0 +1,77 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun ColumnWithScrollbarEdgeShadow(
modifier: Modifier = Modifier,
state: ScrollState = rememberScrollState(),
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
edgeShadowHeight: Dp = 40.dp,
edgeShadowProximity: Dp = 80.dp,
content: @Composable ColumnScope.() -> Unit
) {
val surfaceColor = MaterialTheme.colorScheme.surface
val proximityPx = with(LocalDensity.current) { edgeShadowProximity.toPx() }
val bottomAlpha by remember(state, proximityPx) {
derivedStateOf {
val maxScroll = state.maxValue.takeUnless { it == Int.MAX_VALUE } ?: 0
if (maxScroll == 0 || proximityPx <= 0f) {
0f
} else {
((maxScroll - state.value).coerceAtLeast(0) / proximityPx).coerceIn(0f, 1f)
}
}
}
Box(modifier = modifier) {
Column(
modifier = Modifier
.matchParentSize()
.verticalScroll(state),
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
content = content
)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(edgeShadowHeight)
.graphicsLayer { alpha = bottomAlpha }
.background(
Brush.verticalGradient(
colors = listOf(Color.Transparent, surfaceColor)
)
)
)
if (state.canScrollForward || state.canScrollBackward) {
Scrollbar(state)
}
}
}

View File

@@ -1,6 +1,8 @@
package app.revanced.manager.ui.component
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -9,6 +11,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ConfirmDialog(
onDismiss: () -> Unit,
@@ -20,7 +23,7 @@ fun ConfirmDialog(
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
TextButton(onDismiss) {
TextButton(onDismiss, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -29,7 +32,8 @@ fun ConfirmDialog(
onClick = {
onConfirm()
onDismiss()
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.confirm))
}

View File

@@ -4,8 +4,11 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContentSelector(mime: String, onSelect: (Uri) -> Unit, content: @Composable () -> Unit) {
val activityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
@@ -14,7 +17,8 @@ fun ContentSelector(mime: String, onSelect: (Uri) -> Unit, content: @Composable
Button(
onClick = {
activityLauncher.launch(mime)
}
},
shapes = ButtonDefaults.shapes()
) {
content()
}

View File

@@ -0,0 +1,69 @@
package app.revanced.manager.ui.component
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(
icon: ImageVector,
@StringRes title: Int,
@StringRes description: Int
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.size(100.dp)
.alpha(0.6f),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -19,7 +21,7 @@ import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.ui.component.bundle.BundleTopBar
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) {
val context = LocalContext.current
@@ -52,10 +54,11 @@ fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) {
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
},
shapes = IconButtonDefaults.shapes(),
) {
Icon(
Icons.Outlined.Share,
Icons.Filled.Share,
contentDescription = stringResource(R.string.share)
)
}

View File

@@ -3,7 +3,8 @@ package app.revanced.manager.ui.component
import android.view.WindowManager
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
@@ -11,6 +12,9 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.theme.Theme
import org.koin.compose.koinInject
private val properties = DialogProperties(
usePlatformDefaultWidth = false,
@@ -20,17 +24,22 @@ private val properties = DialogProperties(
@Composable
fun FullscreenDialog(onDismissRequest: () -> Unit, content: @Composable () -> Unit) {
val prefs: PreferencesManager = koinInject()
val theme by prefs.theme.getAsState()
val isDarkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK
Dialog(
onDismissRequest = onDismissRequest,
properties = properties
) {
val view = LocalView.current
val isDarkTheme = isSystemInDarkTheme()
LaunchedEffect(isDarkTheme) {
SideEffect {
val window = (view.parent as DialogWindowProvider).window
WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme

View File

@@ -18,6 +18,9 @@ fun GroupHeader(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(24.dp).semantics { heading() }.then(modifier)
modifier = Modifier
.padding(start = 32.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
.semantics { heading() }
.then(modifier)
)
}

View File

@@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -62,6 +64,7 @@ fun InstallerStatusDialog(installerStatus: Int, model: InstallerModel, onDismiss
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun installerStatusDialogButton(
@StringRes buttonStringResId: Int,
buttonHandler: InstallerStatusDialogButtonHandler = { },
@@ -70,7 +73,8 @@ private fun installerStatusDialogButton(
onClick = {
dismiss()
buttonHandler(model)
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(buttonStringResId))
}

View File

@@ -0,0 +1,133 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun LazyColumnWithScrollbarEdgeShadow(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
edgeShadowHeight: Dp = 40.dp,
edgeShadowProximity: Dp = 80.dp,
content: LazyListScope.() -> Unit
) {
val surfaceColor = MaterialTheme.colorScheme.surface
val proximityPx = with(LocalDensity.current) { edgeShadowProximity.toPx() }
val topAlpha by remember(state, proximityPx) {
derivedStateOf {
if (!state.canScrollBackward || proximityPx <= 0f)
return@derivedStateOf 0f
val layoutInfo = state.layoutInfo
val firstVisible = layoutInfo.visibleItemsInfo.firstOrNull()
?: return@derivedStateOf 0f
if (firstVisible.index > 0)
return@derivedStateOf 1f
val viewportStart = layoutInfo.viewportStartOffset + layoutInfo.beforeContentPadding
val overflowPx = (viewportStart - firstVisible.offset).coerceAtLeast(0)
(overflowPx / proximityPx).coerceIn(0f, 1f)
}
}
val bottomAlpha by remember(state, proximityPx) {
derivedStateOf {
if (!state.canScrollForward || proximityPx <= 0f)
return@derivedStateOf 0f
val layoutInfo = state.layoutInfo
val lastIndex = layoutInfo.totalItemsCount - 1
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()
?: return@derivedStateOf 0f
if (lastVisible.index < lastIndex)
return@derivedStateOf 1f
val viewportEnd = layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding
val overflowPx = (lastVisible.offset + lastVisible.size - viewportEnd)
.coerceAtLeast(0)
(overflowPx / proximityPx).coerceIn(0f, 1f)
}
}
Box(modifier = modifier) {
LazyColumn(
modifier = Modifier.matchParentSize(),
state = state,
contentPadding = contentPadding,
reverseLayout = reverseLayout,
verticalArrangement = verticalArrangement,
horizontalAlignment = horizontalAlignment,
flingBehavior = flingBehavior,
userScrollEnabled = userScrollEnabled,
content = content
)
if (topAlpha > 0f) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(edgeShadowHeight)
.alpha(topAlpha)
.background(
Brush.verticalGradient(
colors = listOf(surfaceColor, Color.Transparent)
)
)
)
}
if (bottomAlpha > 0f) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(edgeShadowHeight)
.alpha(bottomAlpha)
.background(
Brush.verticalGradient(
colors = listOf(Color.Transparent, surfaceColor)
)
)
)
}
if (state.canScrollForward || state.canScrollBackward) {
Scrollbar(state)
}
}
}

View File

@@ -0,0 +1,63 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ListSection(
modifier: Modifier = Modifier,
title: String? = null,
leadingContent: (@Composable () -> Unit)? = null,
contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp),
content: @Composable () -> Unit
) {
Column(modifier = modifier.fillMaxWidth()) {
if (title != null) {
Row(
modifier = Modifier
.padding(start = 32.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
.semantics { heading() },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (leadingContent != null) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
leadingContent()
}
}
Text(
text = title,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge
)
}
}
Column(
modifier = Modifier
.padding(contentPadding)
.clip(MaterialTheme.shapes.large),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
content()
}
}
}

View File

@@ -13,8 +13,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -81,6 +83,7 @@ fun NotificationCard(
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NotificationCard(
text: String,
@@ -138,7 +141,10 @@ fun NotificationCard(
)
}
if (onDismiss != null) {
IconButton(onClick = onDismiss) {
IconButton(
onClick = onDismiss,
shapes = IconButtonDefaults.shapes(),
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.close),

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -18,10 +20,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private inline fun <T> NumberInputDialog(
current: T?,
name: String,
unit: String?,
crossinline onSubmit: (T?) -> Unit,
crossinline validator: @DisallowComposableCalls (T) -> Boolean,
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
@@ -47,6 +51,7 @@ private inline fun <T> NumberInputDialog(
Text(stringResource(R.string.dialog_input_placeholder))
},
isError = validatorFailed,
suffix = unit?.let { { Text(it) } },
supportingText = {
if (validatorFailed) {
Text(
@@ -62,12 +67,13 @@ private inline fun <T> NumberInputDialog(
TextButton(
onClick = { numberFieldValue?.let(onSubmit) },
enabled = numberFieldValue != null && !validatorFailed,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }) {
TextButton(onClick = { onSubmit(null) }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -78,22 +84,25 @@ private inline fun <T> NumberInputDialog(
fun IntInputDialog(
current: Int?,
name: String,
unit: String? = null,
validator: (Int) -> Boolean = { true },
onSubmit: (Int?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toIntOrNull)
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toIntOrNull)
@Composable
fun LongInputDialog(
current: Long?,
name: String,
unit: String? = null,
validator: (Long) -> Boolean = { true },
onSubmit: (Long?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toLongOrNull)
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toLongOrNull)
@Composable
fun FloatInputDialog(
current: Float?,
name: String,
unit: String? = null,
validator: (Float) -> Boolean = { true },
onSubmit: (Float?) -> Unit
) = NumberInputDialog(current, name, onSubmit, validator, String::toFloatOrNull)
) = NumberInputDialog(current, name, unit, onSubmit, validator, String::toFloatOrNull)

View File

@@ -4,8 +4,10 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -20,6 +22,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PasswordField(modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null) {
var visible by rememberSaveable {
@@ -35,7 +38,7 @@ fun PasswordField(modifier: Modifier = Modifier, value: String, onValueChange: (
trailingIcon = {
IconButton(onClick = {
visible = !visible
}) {
}, shapes = IconButtonDefaults.shapes()) {
val (icon, description) = remember(visible) {
if (visible) Icons.Outlined.VisibilityOff to R.string.hide_password_field else Icons.Outlined.Visibility to R.string.show_password_field
}

View File

@@ -0,0 +1,283 @@
package app.revanced.manager.ui.component
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun PillTabBar(
pagerState: PagerState,
modifier: Modifier = Modifier,
colors: PillTabBarColors = PillTabBarDefaults.colors(),
tabs: @Composable RowScope.() -> Unit
) {
val tabCount = pagerState.pageCount.coerceAtLeast(1)
val state = rememberPillTabBarState(pagerState, tabCount)
val indicatorScale by animatePillTabScale(state.pressedTabIndex == pagerState.currentPage)
BoxWithConstraints(
modifier = modifier
.height(PillTabBarDefaults.ContainerHeight)
.clip(CircleShape)
.background(colors.containerColor)
.padding(PillTabBarDefaults.ContainerPadding)
) {
val indicatorWidthPx = with(LocalDensity.current) { maxWidth.toPx() } / tabCount
val offsetX = (pagerState.currentPage + pagerState.currentPageOffsetFraction) * indicatorWidthPx
PillTabIndicator(
offsetX = offsetX,
tabCount = tabCount,
currentPage = pagerState.currentPage,
scale = indicatorScale,
color = colors.indicatorColor
)
CompositionLocalProvider(
LocalPillTabBarState provides state,
LocalPillTabBarColors provides colors
) {
Row(
modifier = Modifier.matchParentSize(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
content = tabs
)
}
}
}
@Composable
fun RowScope.PillTab(
index: Int,
onClick: () -> Unit,
text: @Composable () -> Unit,
modifier: Modifier = Modifier,
icon: (@Composable () -> Unit)? = null
) {
val view = LocalView.current
val state = LocalPillTabBarState.current
val colors = LocalPillTabBarColors.current
val isSelected = state.pagerState.currentPage == index
val contentScale by animatePillTabScale(state.pressedTabIndex == index)
val hasComposed = remember { mutableStateOf(false) }
LaunchedEffect(isSelected) {
if (!hasComposed.value) {
hasComposed.value = true
return@LaunchedEffect
}
if (isSelected) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY)
}
}
Box(
modifier = modifier
.weight(1f)
.fillMaxHeight()
.semantics {
role = Role.Tab
selected = isSelected
}
.graphicsLayer {
scaleX = contentScale
scaleY = contentScale
transformOrigin = transformOriginForIndex(index, state.tabCount)
}
.pointerInput(Unit) {
detectTapGestures(
onPress = {
state.onPressedTabIndexChange(index)
try {
awaitRelease()
} finally {
state.onPressedTabIndexChange(-1)
}
},
onTap = { onClick() }
)
},
contentAlignment = Alignment.Center
) {
PillTabContent(
isSelected = isSelected,
colors = colors,
icon = icon,
text = text
)
}
}
@Composable
private fun PillTabIndicator(
offsetX: Float,
tabCount: Int,
currentPage: Int,
scale: Float,
color: Color
) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(1f / tabCount)
.offset { IntOffset(offsetX.roundToInt(), 0) }
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = transformOriginForIndex(currentPage, tabCount)
}
.clip(CircleShape)
.background(color)
)
}
@Composable
private fun PillTabContent(
isSelected: Boolean,
colors: PillTabBarColors,
icon: (@Composable () -> Unit)?,
text: @Composable () -> Unit
) {
val contentColor = if (isSelected) colors.selectedContentColor else colors.unselectedContentColor
val fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
CompositionLocalProvider(LocalContentColor provides contentColor) {
ProvideTextStyle(MaterialTheme.typography.labelLarge.copy(fontWeight = fontWeight)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(PillTabBarDefaults.IconTextSpacing)
) {
icon?.invoke()
text()
}
}
}
}
@Composable
private fun rememberPillTabBarState(
pagerState: PagerState,
tabCount: Int
): PillTabBarState {
var pressedTabIndex by remember { mutableIntStateOf(-1) }
return remember(pagerState, tabCount, pressedTabIndex) {
PillTabBarState(
pagerState = pagerState,
tabCount = tabCount,
pressedTabIndex = pressedTabIndex,
onPressedTabIndexChange = { pressedTabIndex = it }
)
}
}
@Composable
private fun animatePillTabScale(isPressed: Boolean) = animateFloatAsState(
targetValue = if (isPressed) PillTabBarDefaults.PressedScale else 1f,
animationSpec = PillTabBarDefaults.PressAnimationSpec,
label = "pillTabScale"
)
private fun transformOriginForIndex(index: Int, count: Int) = TransformOrigin(
pivotFractionX = when (index) {
0 -> 0f
count - 1 -> 1f
else -> 0.5f
},
pivotFractionY = 0.5f
)
object PillTabBarDefaults {
val ContainerHeight: Dp = 48.dp
val ContainerPadding: Dp = 4.dp
val IconTextSpacing: Dp = 6.dp
internal const val PressedScale = 0.99f
internal val PressAnimationSpec = tween<Float>(
durationMillis = 80,
easing = CubicBezierEasing(0.2f, 0f, 0.4f, 1f)
)
@Composable
fun colors(
containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
indicatorColor: Color = MaterialTheme.colorScheme.primaryContainer,
selectedContentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
unselectedContentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
) = PillTabBarColors(
containerColor = containerColor,
indicatorColor = indicatorColor,
selectedContentColor = selectedContentColor,
unselectedContentColor = unselectedContentColor
)
}
@Immutable
data class PillTabBarColors(
val containerColor: Color,
val indicatorColor: Color,
val selectedContentColor: Color,
val unselectedContentColor: Color
)
@Immutable
private data class PillTabBarState(
val pagerState: PagerState,
val tabCount: Int,
val pressedTabIndex: Int,
val onPressedTabIndexChange: (Int) -> Unit
)
private val LocalPillTabBarState = staticCompositionLocalOf<PillTabBarState> {
error("PillTab must be used within PillTabBar")
}
private val LocalPillTabBarColors = staticCompositionLocalOf<PillTabBarColors> {
error("PillTab must be used within PillTabBar")
}

View File

@@ -1,11 +1,15 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarColors
@@ -20,13 +24,14 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SearchView(
query: String,
onQueryChange: (String) -> Unit,
onActiveChange: (Boolean) -> Unit,
placeholder: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = SearchBarColors(
@@ -49,12 +54,26 @@ fun SearchView(
onExpandedChange = onActiveChange,
placeholder = placeholder,
leadingIcon = {
IconButton(onClick = { onActiveChange(false) }) {
IconButton(onClick = { onActiveChange(false) }, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.back)
)
}
},
trailingIcon = {
Row {
trailingContent?.invoke()
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.clear)
)
}
}
}
}
)
},

View File

@@ -0,0 +1,250 @@
package app.revanced.manager.ui.component
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import com.google.accompanist.drawablepainter.rememberDrawablePainter
private data class ResolvedShareTarget(
val key: String,
val label: String,
val icon: Drawable?,
val componentName: ComponentName,
)
private fun resolveShareTargets(context: Context, uri: Uri): List<ResolvedShareTarget> {
val packageManager = context.packageManager
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val resolvedActivities: List<ResolveInfo> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.queryIntentActivities(
sendIntent,
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())
)
} else {
packageManager.queryIntentActivities(sendIntent, PackageManager.MATCH_DEFAULT_ONLY)
}
return resolvedActivities.mapNotNull { resolved ->
val info = resolved.activityInfo ?: return@mapNotNull null
ResolvedShareTarget(
key = "${info.packageName}:${info.name}",
label = resolved.loadLabel(packageManager).toString(),
icon = resolved.loadIcon(packageManager),
componentName = ComponentName(info.packageName, info.name),
)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ShareSheet(
modifier: Modifier = Modifier,
onDismissRequest: () -> Unit,
title: String,
preview: String? = null,
shareUri: Uri?,
onSaveToFilesClick: () -> Unit
) {
val context = LocalContext.current
val shareTargets = remember(shareUri) {
shareUri?.let { resolveShareTargets(context, it) } ?: emptyList()
}
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp)
)
if (preview != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large)
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerLow
) {
Text(
text = preview.ifBlank { stringResource(R.string.loading) },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
maxLines = 7,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp)
)
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(42.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.surfaceContainerLow
)
)
)
)
}
}
if (shareTargets.isNotEmpty()) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items = shareTargets, key = { it.key }) { target ->
ShareTarget(
label = target.label,
icon = target.icon,
onClick = {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, shareUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
component = target.componentName
}
context.startActivity(intent)
onDismissRequest()
}
)
}
}
}
ListSection(
contentPadding = PaddingValues(horizontal = 16.dp)
) {
SegmentedListItem(
onClick = onSaveToFilesClick,
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 1),
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
leadingContent = {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = null
)
}
) {
Text(text = stringResource(R.string.save_as_file))
}
}
}
}
}
@Composable
private fun ShareTarget(label: String, icon: Drawable?, onClick: () -> Unit) {
Column(
modifier = Modifier.width(80.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(62.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainer)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (icon != null) {
Icon(
painter = rememberDrawablePainter(icon),
contentDescription = label,
tint = Color.Unspecified,
modifier = Modifier.size(54.dp)
)
} else {
Icon(
imageVector = Icons.Default.Android,
contentDescription = label,
modifier = Modifier.size(54.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -1,6 +1,8 @@
package app.revanced.manager.ui.component
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
@@ -11,6 +13,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TextInputDialog(
initial: String,
@@ -31,13 +34,14 @@ fun TextInputDialog(
confirmButton = {
TextButton(
onClick = { onConfirm(value) },
enabled = valid
enabled = valid,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},

View File

@@ -0,0 +1,98 @@
package app.revanced.manager.ui.component
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TrustDialog(
@StringRes title: Int,
body: String,
downloaderName: String,
signature: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.action_trust))
}
},
dismissButton = {
TextButton(onClick = onDismiss, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(body)
Card {
Column(
Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(
R.string.downloader_trust_dialog_name,
downloaderName
),
)
OutlinedCard(
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
SelectionContainer(
Modifier.padding(12.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
DisableSelection {
Text(
stringResource(
R.string.downloader_trust_dialog_signature,
),
fontFamily = FontFamily.Monospace
)
}
Text(
signature.chunked(2).joinToString(" "),
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
}
}
)
}

View File

@@ -1,327 +0,0 @@
package app.revanced.manager.ui.component.bundle
import android.webkit.URLUtil.isValidUrl
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Commit
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.R.string.auto_update
import app.revanced.manager.R.string.auto_update_description
import app.revanced.manager.R.string.field_not_set
import app.revanced.manager.R.string.patches
import app.revanced.manager.R.string.patches_url
import app.revanced.manager.R.string.view_patches
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.ui.component.haptics.HapticSwitch
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BundleInformationDialog(
src: PatchBundleSource,
patchCount: Int,
onDismissRequest: () -> Unit,
onDeleteRequest: () -> Unit,
onUpdate: () -> Unit,
) {
val bundleRepo = koinInject<PatchBundleRepository>()
val networkInfo = koinInject<NetworkInfo>()
val prefs = koinInject<PreferencesManager>()
val hasNetwork = remember { networkInfo.isConnected() }
val composableScope = rememberCoroutineScope()
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
val isLocal = src is LocalPatchBundle
val bundleManifestAttributes = src.patchBundle?.manifestAttributes
val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint }
?: (null to null)
fun onAutoUpdateChange(new: Boolean) = composableScope.launch {
with(bundleRepo) {
src.asRemoteOrNull?.setAutoUpdate(new)
}
}
if (viewCurrentBundlePatches) {
BundlePatchesDialog(
src = src,
onDismissRequest = {
viewCurrentBundlePatches = false
}
)
}
FullscreenDialog(
onDismissRequest = onDismissRequest,
) {
Scaffold(
topBar = {
BundleTopBar(
title = src.name,
onBackClick = onDismissRequest,
backIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
},
actions = {
if (!src.isDefault) {
IconButton(onClick = onDeleteRequest) {
Icon(
Icons.Outlined.DeleteOutline,
stringResource(R.string.delete)
)
}
}
if (!isLocal && hasNetwork) {
IconButton(onClick = onUpdate) {
Icon(
Icons.Outlined.Update,
stringResource(R.string.refresh)
)
}
}
}
)
},
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Tag(Icons.Outlined.Sell, src.name)
bundleManifestAttributes?.description?.let {
Tag(Icons.Outlined.Description, it)
}
bundleManifestAttributes?.source?.let {
Tag(Icons.Outlined.Commit, it)
}
bundleManifestAttributes?.author?.let {
Tag(Icons.Outlined.Person, it)
}
bundleManifestAttributes?.contact?.let {
Tag(Icons.AutoMirrored.Outlined.Send, it)
}
bundleManifestAttributes?.website?.let {
Tag(Icons.Outlined.Language, it, isUrl = true)
}
bundleManifestAttributes?.license?.let {
Tag(Icons.Outlined.Gavel, it)
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
if (autoUpdate != null) {
BundleListItem(
headlineText = stringResource(auto_update),
supportingText = stringResource(auto_update_description),
trailingContent = {
HapticSwitch(
checked = autoUpdate,
onCheckedChange = ::onAutoUpdateChange
)
},
modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
}
)
}
if (src.isDefault) {
val useBundlePrerelease by prefs.usePatchesPrereleases.getAsState()
BundleListItem(
headlineText = stringResource(R.string.patches_prereleases),
supportingText = stringResource(R.string.patches_prereleases_description, src.name),
trailingContent = {
HapticSwitch(
checked = useBundlePrerelease,
onCheckedChange = {
composableScope.launch {
prefs.usePatchesPrereleases.update(
it
)
onUpdate()
}
}
)
},
modifier = Modifier.clickable {
composableScope.launch {
prefs.usePatchesPrereleases.update(!useBundlePrerelease)
onUpdate()
}
}
)
}
endpoint?.takeUnless { src.isDefault }?.let { url ->
var showUrlInputDialog by rememberSaveable {
mutableStateOf(false)
}
if (showUrlInputDialog) {
TextInputDialog(
initial = url,
title = stringResource(patches_url),
onDismissRequest = { showUrlInputDialog = false },
onConfirm = {
showUrlInputDialog = false
TODO("Not implemented.")
},
validator = {
if (it.isEmpty()) return@TextInputDialog false
isValidUrl(it)
}
)
}
BundleListItem(
modifier = Modifier.clickable(
enabled = false,
onClick = {
showUrlInputDialog = true
}
),
headlineText = stringResource(patches_url),
supportingText = url.ifEmpty {
stringResource(field_not_set)
}
)
}
val patchesClickable = patchCount > 0
BundleListItem(
headlineText = stringResource(patches),
supportingText = stringResource(view_patches),
modifier = Modifier.clickable(
enabled = patchesClickable,
onClick = {
viewCurrentBundlePatches = true
}
)
) {
if (patchesClickable) {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
stringResource(patches)
)
}
}
src.error?.let {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
if (showDialog) ExceptionViewerDialog(
onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() }
)
BundleListItem(
headlineText = stringResource(R.string.patches_error),
supportingText = stringResource(R.string.patches_error_description),
trailingContent = {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
null
)
},
modifier = Modifier.clickable { showDialog = true }
)
}
if (src.state is PatchBundleSource.State.Missing && !isLocal) {
BundleListItem(
headlineText = stringResource(R.string.patches_error),
supportingText = stringResource(R.string.patches_not_downloaded),
modifier = Modifier.clickable(onClick = onUpdate)
)
}
}
}
}
}
@Composable
private fun Tag(
icon: ImageVector,
text: String,
isUrl: Boolean = false
) {
val uriHandler = LocalUriHandler.current
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = if (isUrl) {
Modifier
.clickable {
try {
uriHandler.openUri(text)
} catch (_: Exception) {
}
}
} else
Modifier,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = if (isUrl) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
)
}
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
@@ -15,18 +14,13 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.haptics.HapticCheckbox
@OptIn(ExperimentalFoundationApi::class)
@@ -38,41 +32,14 @@ fun BundleItem(
isBundleSelected: Boolean,
toggleSelection: (Boolean) -> Unit,
onSelect: () -> Unit,
onDelete: () -> Unit,
onUpdate: () -> Unit,
onClick: () -> Unit,
) {
var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) }
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
if (viewBundleDialogPage) {
BundleInformationDialog(
src = src,
patchCount = patchCount,
onDismissRequest = { viewBundleDialogPage = false },
onDeleteRequest = { showDeleteConfirmationDialog = true },
onUpdate = onUpdate,
)
}
if (showDeleteConfirmationDialog) {
ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = {
onDelete()
viewBundleDialogPage = false
},
title = stringResource(R.string.delete),
description = stringResource(R.string.patches_delete_single_dialog_description, src.name),
icon = Icons.Outlined.Delete
)
}
ListItem(
modifier = Modifier
.height(64.dp)
.fillMaxWidth()
.combinedClickable(
onClick = { viewBundleDialogPage = true },
onClick = onClick,
onLongClick = onSelect,
),
leadingContent = if (selectable) {

View File

@@ -1,33 +0,0 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun BundleListItem(
modifier: Modifier = Modifier,
headlineText: String,
supportingText: String = "",
trailingContent: @Composable (() -> Unit)? = null,
) {
ListItem(
headlineContent = {
Text(
text = headlineText,
style = MaterialTheme.typography.titleLarge
)
},
supportingContent = {
Text(
text = supportingText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
},
trailingContent = trailingContent,
modifier = modifier
)
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -31,56 +33,143 @@ import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.SearchView
import kotlinx.coroutines.flow.mapNotNull
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BundlePatchesDialog(
onDismissRequest: () -> Unit,
src: PatchBundleSource,
) {
var showAllVersions by rememberSaveable { mutableStateOf(false) }
var showOptions by rememberSaveable { mutableStateOf(false) }
val patchBundleRepository: PatchBundleRepository = koinInject()
var query by rememberSaveable { mutableStateOf("") }
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val patches by remember(src.uid) {
patchBundleRepository.bundleInfoFlow.mapNotNull { it[src.uid]?.patches }
}.collectAsStateWithLifecycle(emptyList())
val filteredPatches = remember(patches, query) {
if (query.isEmpty()) {
patches
} else {
patches.filter { patch ->
patch.name.contains(query, ignoreCase = true) ||
patch.description?.contains(query, ignoreCase = true) == true ||
patch.compatiblePackages?.any { compatiblePackage ->
compatiblePackage.packageName.contains(query, ignoreCase = true) ||
compatiblePackage.versions?.any { version ->
version.contains(query, ignoreCase = true)
} == true
} == true
}
}
}
FullscreenDialog(
onDismissRequest = onDismissRequest,
) {
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.patches),
onBackClick = onDismissRequest,
backIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
},
)
},
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(16.dp)
if (isSearchActive) {
SearchView(
query = query,
onQueryChange = { query = it },
onActiveChange = {
isSearchActive = it
if (!it) query = ""
},
placeholder = { Text(stringResource(R.string.search)) }
) {
items(patches) { patch ->
PatchItem(
patch,
showAllVersions,
onExpandVersions = { showAllVersions = !showAllVersions },
showOptions,
onExpandOptions = { showOptions = !showOptions }
)
when {
query.isEmpty() -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = stringResource(R.string.search_patches),
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.type_anything),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
filteredPatches.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.no_patch_found),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
PatchList(
patches = filteredPatches,
modifier = Modifier.fillMaxSize()
)
}
}
}
} else {
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.patches),
onBackClick = onDismissRequest,
actions = {
IconButton(
onClick = { isSearchActive = true },
shapes = IconButtonDefaults.shapes(),
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.search_patches)
)
}
},
backIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
},
)
},
) { paddingValues ->
PatchList(
patches = patches,
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
)
}
}
}
}
@Composable
private fun PatchList(
patches: List<PatchInfo>,
modifier: Modifier = Modifier
) {
LazyColumnWithScrollbar(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(16.dp)
) {
items(items = patches) { patch ->
PatchItem(patch)
}
}
}
@@ -88,19 +177,18 @@ fun BundlePatchesDialog(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PatchItem(
patch: PatchInfo,
expandVersions: Boolean,
onExpandVersions: () -> Unit,
expandOptions: Boolean,
onExpandOptions: () -> Unit
patch: PatchInfo
) {
var expandedVersionPackages by rememberSaveable { mutableStateOf(setOf<String>()) }
var expandOptions by rememberSaveable { mutableStateOf(false) }
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.then(
if (patch.options.isNullOrEmpty()) Modifier else Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onExpandOptions),
.clickable { expandOptions = !expandOptions },
)
) {
Column(
@@ -148,6 +236,7 @@ fun PatchItem(
patch.compatiblePackages.forEach { compatiblePackage ->
val packageName = compatiblePackage.packageName
val versions = compatiblePackage.versions.orEmpty().reversed()
val expandVersions = packageName in expandedVersionPackages
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -174,7 +263,13 @@ fun PatchItem(
}
if (versions.size > 1) {
PatchInfoChip(
onClick = onExpandVersions,
onClick = {
expandedVersionPackages = if (expandVersions) {
expandedVersionPackages - packageName
} else {
expandedVersionPackages + packageName
}
},
text = if (expandVersions) stringResource(R.string.less) else "+${versions.size - 1}"
)
}

View File

@@ -1,27 +1,32 @@
package app.revanced.manager.ui.component.bundle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BundleSelector(sources: List<PatchBundleSource>, onFinish: (PatchBundleSource?) -> Unit) {
fun BundleSelector(
sources: List<PatchBundleSource>,
title: String? = null,
onFinish: (PatchBundleSource?) -> Unit
) {
LaunchedEffect(sources) {
if (sources.size == 1) {
onFinish(sources[0])
@@ -36,38 +41,37 @@ fun BundleSelector(sources: List<PatchBundleSource>, onFinish: (PatchBundleSourc
onDismissRequest = { onFinish(null) }
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
Text(
text = title ?: stringResource(R.string.select),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp)
)
Column(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(MaterialTheme.shapes.large),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
Text(
text = stringResource(R.string.select),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
sources.forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.clickable {
onFinish(it)
}
) {
Text(
"${it.name} ${it.version}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
sources.forEachIndexed { index, source ->
SegmentedListItem(
onClick = { onFinish(source) },
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
shapes = ListItemDefaults.segmentedShapes(index = index, count = sources.size)
) {
Text(
"${source.name} ${source.version}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}

View File

@@ -2,7 +2,9 @@ package app.revanced.manager.ui.component.bundle
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -12,7 +14,7 @@ import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BundleTopBar(
title: String,
@@ -33,7 +35,10 @@ fun BundleTopBar(
scrollBehavior = scrollBehavior,
navigationIcon = {
if (onBackClick != null) {
IconButton(onClick = onBackClick) {
IconButton(
onClick = onBackClick,
shapes = IconButtonDefaults.shapes(),
) {
backIcon()
}
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Topic
import androidx.compose.material3.*
@@ -31,6 +33,7 @@ private enum class BundleType {
Remote
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportPatchBundleDialog(
onDismiss: () -> Unit,
@@ -95,23 +98,24 @@ fun ImportPatchBundleDialog(
BundleType.Local -> patchBundle?.let(onLocalSubmit)
BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate)
}
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.add))
}
} else {
TextButton(onClick = { currentStep++ }) {
TextButton(onClick = { currentStep++ }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.next))
}
}
},
dismissButton = {
if (currentStep > 0) {
TextButton(onClick = { currentStep-- }) {
TextButton(onClick = { currentStep-- }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.back))
}
} else {
TextButton(onClick = onDismiss) {
TextButton(onClick = onDismiss, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
}
@@ -126,6 +130,7 @@ private fun SelectBundleTypeStep(
onBundleTypeSelected: (BundleType) -> Unit
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
@@ -170,7 +175,7 @@ private fun SelectBundleTypeStep(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ImportBundleStep(
bundleType: BundleType,
@@ -181,7 +186,7 @@ private fun ImportBundleStep(
onRemoteUrlChange: (String) -> Unit,
onAutoUpdateChange: (Boolean) -> Unit
) {
Column {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
when (bundleType) {
BundleType.Local -> {
Column(
@@ -193,7 +198,7 @@ private fun ImportBundleStep(
},
supportingContent = { Text(stringResource(if (patchBundle != null) R.string.file_field_set else R.string.file_field_not_set)) },
trailingContent = {
IconButton(onClick = launchPatchActivity) {
IconButton(onClick = launchPatchActivity, shapes = IconButtonDefaults.shapes()) {
Icon(imageVector = Icons.Default.Topic, contentDescription = null)
}
},

View File

@@ -1,6 +1,6 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxColors
@@ -21,7 +21,7 @@ fun HapticCheckbox(
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange?.withHapticFeedback(HapticFeedbackConstants.CLOCK_TICK),
onCheckedChange = onCheckedChange?.withHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK),
modifier = modifier,
enabled = enabled,
colors = colors,

View File

@@ -1,6 +1,6 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
@@ -29,7 +29,7 @@ fun HapticExtendedFloatingActionButton (
ExtendedFloatingActionButton(
text = text,
icon = icon,
onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY),
modifier = modifier,
expanded = expanded,
shape = shape,

View File

@@ -1,6 +1,6 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
@@ -25,7 +25,7 @@ fun HapticFloatingActionButton (
content: @Composable () -> Unit,
) {
FloatingActionButton(
onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY),
modifier = modifier,
shape = shape,
containerColor = containerColor,

View File

@@ -1,6 +1,6 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonColors
@@ -26,7 +26,7 @@ fun HapticRadioButton(
onClick = onClick?.let {
{
// Perform haptic feedback
if (!selected) view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
if (!selected) view.performHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK)
it()
}
},

View File

@@ -1,7 +1,6 @@
package app.revanced.manager.ui.component.haptics
import android.os.Build
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchColors
@@ -25,15 +24,9 @@ fun HapticSwitch(
Switch(
checked = checked,
onCheckedChange = { newChecked ->
val useNewConstants = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
val hapticFeedbackType = when {
newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_ON
newChecked -> HapticFeedbackConstants.VIRTUAL_KEY
!newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_OFF
!newChecked -> HapticFeedbackConstants.CLOCK_TICK
else -> {HapticFeedbackConstants.VIRTUAL_KEY}
}
view.performHapticFeedback(hapticFeedbackType)
view.performHapticFeedback(
if (newChecked) HapticFeedbackConstantsCompat.TOGGLE_ON else HapticFeedbackConstantsCompat.TOGGLE_OFF
)
onCheckedChange(newChecked)
},
modifier = modifier,

View File

@@ -1,6 +1,6 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Tab
@@ -24,7 +24,7 @@ fun HapticTab (
) {
Tab(
selected = selected,
onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY),
modifier = modifier,
enabled = enabled,
text = text,

View File

@@ -1,6 +1,6 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.CheckboxColors
import androidx.compose.material3.CheckboxDefaults
@@ -22,7 +22,7 @@ fun HapticTriStateCheckbox(
) {
TriStateCheckbox(
state = state,
onClick = onClick?.withHapticFeedback(HapticFeedbackConstants.CLOCK_TICK),
onClick = onClick?.withHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK),
modifier = modifier,
enabled = enabled,
colors = colors,

View File

@@ -0,0 +1,208 @@
package app.revanced.manager.ui.component.onboarding
import android.content.Context
import android.content.pm.PackageInfo
import android.graphics.Bitmap
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import androidx.annotation.FloatRange
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.Image
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Android
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.graphics.scale
import app.revanced.manager.R
import app.revanced.manager.util.blurBackground
@Composable
fun OnboardingAppCard(
packageName: String,
patchCount: Int,
packageInfo: PackageInfo?,
suggestedVersion: String?,
loadAppLabel: () -> String?,
loadAppIcon: () -> ImageBitmap?,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val isInstalled = packageInfo != null
val versionName = remember(packageName) { packageInfo?.versionName }
// Extra app data is loaded async
var appLabel by remember(packageName) { mutableStateOf<String?>(null) }
var appIcon by remember { mutableStateOf<ImageBitmap?>(null) }
var appIconBlur by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(packageName) {
appLabel = loadAppLabel()
appIcon = loadAppIcon()
appIconBlur = appIcon?.let {
blurBackground(context, it.asAndroidBitmap(), 18f).asImageBitmap()
}
}
Card(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.clickable(onClick = onClick),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
)
) {
Box {
// https://stackoverflow.com/a/68742173/13964629
androidx.compose.animation.AnimatedVisibility(
visible = appIconBlur != null,
enter = fadeIn(),
exit = ExitTransition.None,
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.clipToBounds(),
) {
Image(
bitmap = appIconBlur!!,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
)
Box(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.75f))
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Crossfade(
targetState = appIcon,
animationSpec = tween(durationMillis = 100),
) { appIcon ->
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (appIcon != null) {
Image(
bitmap = appIcon,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
} else {
Image(
painter = rememberVectorPainter(Icons.Default.Android),
contentDescription = null,
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant)
)
}
}
}
Column(Modifier.weight(1f)) {
Text(
text = appLabel ?: packageName,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 1500,
initialDelayMillis = 2500,
spacing = MarqueeSpacing.fractionOfContainer(1f / 5f),
velocity = 55.dp,
),
fontWeight = FontWeight.SemiBold,
style = if (isInstalled) {
MaterialTheme.typography.titleMedium
} else {
MaterialTheme.typography.titleSmall
},
color = if (isInstalled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
Text(
text = versionName
?: suggestedVersion?.let {
stringResource(R.string.onboarding_recommended_version, it)
}
?: stringResource(R.string.not_installed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = pluralStringResource(R.plurals.patch_count, patchCount, patchCount),
style = MaterialTheme.typography.labelMedium,
color = if (isInstalled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}

View File

@@ -0,0 +1,79 @@
package app.revanced.manager.ui.component.onboarding
import android.content.pm.PackageInfo
import android.graphics.Bitmap
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ListItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import app.revanced.manager.ui.component.LazyColumnWithScrollbarEdgeShadow
import app.revanced.manager.util.AppInfo
import java.util.Optional
import java.util.concurrent.ConcurrentHashMap
import kotlin.jvm.optionals.getOrNull
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
fun OnboardingAppList(
modifier: Modifier = Modifier,
apps: List<AppInfo>,
suggestedVersions: Map<String, String?>,
onAppClick: (String) -> Unit,
) {
val pm = LocalContext.current.packageManager
val appIcons = remember { ConcurrentHashMap<String, Optional<ImageBitmap>>() }
val appLabels = remember { ConcurrentHashMap<String, String>() }
fun getAppIcon(packageInfo: PackageInfo): ImageBitmap? {
return appIcons.computeIfAbsent(packageInfo.packageName) {
val icon = packageInfo.applicationInfo
?.loadIcon(pm)
?.toBitmap(width = 128, height = 128, Bitmap.Config.ARGB_8888)
?.asImageBitmap()
Optional.ofNullable(icon)
}.getOrNull()
}
fun getAppLabel(packageInfo: PackageInfo): String {
return appLabels.computeIfAbsent(packageInfo.packageName) {
packageInfo.applicationInfo!!.loadLabel(pm).toString()
}
}
LazyColumnWithScrollbarEdgeShadow(
modifier = modifier.fillMaxSize(),
state = rememberLazyListState(
cacheWindow = LazyLayoutCacheWindow(ahead = 100.dp, behind = 250.dp),
),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
items(
items = apps,
key = { app -> app.packageName }
) { app ->
OnboardingAppCard(
packageName = app.packageName,
patchCount = app.patches ?: 0,
packageInfo = app.packageInfo,
suggestedVersion = suggestedVersions[app.packageName],
loadAppLabel = { app.packageInfo?.let(::getAppLabel) },
loadAppIcon = { app.packageInfo?.let(::getAppIcon) },
onClick = { onAppClick(app.packageName) },
)
}
}
}

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.ui.component.patcher
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -15,6 +17,7 @@ import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun InstallPickerDialog(
onDismiss: () -> Unit,
@@ -25,7 +28,7 @@ fun InstallPickerDialog(
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
TextButton(onClick = onDismiss) {
TextButton(onClick = onDismiss, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -34,14 +37,15 @@ fun InstallPickerDialog(
onClick = {
onConfirm(selectedInstallType)
onDismiss()
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.install_app))
}
},
title = { Text(stringResource(R.string.select_install_type)) },
text = {
Column {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
InstallType.entries.forEach {
ListItem(
modifier = Modifier.clickable { selectedInstallType = it },

View File

@@ -20,20 +20,23 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -121,9 +124,13 @@ private class OptionEditorScope<T : Any>(
private interface OptionEditor<T : Any> {
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
IconButton(onClick = { scope.checkSafeguard { clickAction(scope) } }) {
IconButton(
onClick = { scope.checkSafeguard { clickAction(scope) } },
shapes = IconButtonDefaults.shapes(),
) {
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
}
}
@@ -221,6 +228,7 @@ fun <T : Any> OptionItem(
}
private object StringOptionEditor : OptionEditor<String> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
override fun Dialog(scope: OptionEditorScope<String>) {
var showFileDialog by rememberSaveable { mutableStateOf(false) }
@@ -271,7 +279,8 @@ private object StringOptionEditor : OptionEditor<String> {
trailingIcon = {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
IconButton(
onClick = { showDropdownMenu = true }
onClick = { showDropdownMenu = true },
shapes = IconButtonDefaults.shapes(),
) {
Icon(
Icons.Outlined.MoreVert,
@@ -306,12 +315,14 @@ private object StringOptionEditor : OptionEditor<String> {
confirmButton = {
TextButton(
enabled = !validatorFailed,
onClick = { scope.submitDialog(fieldValue) }) {
onClick = { scope.submitDialog(fieldValue) },
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
TextButton(onClick = scope.dismissDialog, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -345,7 +356,7 @@ private object IntOptionEditor : NumberOptionEditor<Int>() {
current: Int?,
validator: (Int?) -> Boolean,
onSubmit: (Int?) -> Unit
) = IntInputDialog(current, title, validator, onSubmit)
) = IntInputDialog(current, title, unit = null, validator, onSubmit)
}
private object LongOptionEditor : NumberOptionEditor<Long>() {
@@ -355,7 +366,7 @@ private object LongOptionEditor : NumberOptionEditor<Long>() {
current: Long?,
validator: (Long?) -> Boolean,
onSubmit: (Long?) -> Unit
) = LongInputDialog(current, title, validator, onSubmit)
) = LongInputDialog(current, title, unit = null, validator, onSubmit)
}
private object FloatOptionEditor : NumberOptionEditor<Float>() {
@@ -365,7 +376,7 @@ private object FloatOptionEditor : NumberOptionEditor<Float>() {
current: Float?,
validator: (Float?) -> Boolean,
onSubmit: (Float?) -> Unit
) = FloatInputDialog(current, title, validator, onSubmit)
) = FloatInputDialog(current, title, unit = null, validator, onSubmit)
}
private object BooleanOptionEditor : OptionEditor<Boolean> {
@@ -408,6 +419,7 @@ private object UnknownTypeEditor : OptionEditor<Any>, KoinComponent {
*/
private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<T>) :
OptionEditor<T> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
var selectedPreset by rememberSaveable(scope.value, scope.option.presets) {
@@ -445,13 +457,14 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
// Hide the presets dialog so it doesn't show up in the background.
hidePresetsDialog = true
}
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
TextButton(onClick = scope.dismissDialog, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -504,7 +517,9 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
null
) { true }
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
override fun Dialog(scope: OptionEditorScope<List<T>>) {
val items =
@@ -583,10 +598,11 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
onClick = {
if (items.size == deletionTargets.size) deletionTargets.clear()
else deletionTargets.addAll(items.map { it.key })
}
},
shapes = IconButtonDefaults.shapes(),
) {
Icon(
Icons.Outlined.SelectAll,
Icons.Filled.SelectAll,
stringResource(R.string.select_deselect_all)
)
}
@@ -595,16 +611,20 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
items.removeIf { it.key in deletionTargets }
deletionTargets.clear()
deleteMode = false
}
},
shapes = IconButtonDefaults.shapes(),
) {
Icon(
Icons.Outlined.Delete,
Icons.Filled.Delete,
stringResource(R.string.delete)
)
}
} else {
IconButton(onClick = items::clear) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
IconButton(
onClick = items::clear,
shapes = IconButtonDefaults.shapes(),
) {
Icon(Icons.Filled.Restore, stringResource(R.string.reset))
}
}
}
@@ -675,6 +695,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
IconButton(
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
onClick = {},
shapes = IconButtonDefaults.shapes(),
) {
Icon(
Icons.Filled.DragHandle,

View File

@@ -0,0 +1,394 @@
package app.revanced.manager.ui.component.scaffold
import android.os.Build
import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import app.revanced.manager.R
import kotlin.math.roundToInt
@Stable
class BannerScope internal constructor(
val collapseFraction: Float,
val isLandscape: Boolean,
val contentColor: Color,
)
@Stable
class BannerScrollState(initialCollapsedFraction: Float = 0f) {
private var collapseOffsetPx by mutableFloatStateOf(0f)
private var collapseRangePx by mutableFloatStateOf(0f)
var collapsedFraction by mutableFloatStateOf(initialCollapsedFraction.coerceIn(0f, 1f))
private set
internal var offsetLimitPx: Float
get() = collapseRangePx
set(value) {
collapseRangePx = value.coerceAtLeast(0f)
if (collapseRangePx == 0f) {
collapseOffsetPx = 0f
collapsedFraction = 0f
return
}
collapseOffsetPx = (collapsedFraction * collapseRangePx).coerceIn(0f, collapseRangePx)
collapsedFraction = collapseOffsetPx / collapseRangePx
}
internal fun consumeScrollDelta(deltaPx: Float): Float {
if (collapseRangePx <= 0f || deltaPx == 0f) return 0f
val previousOffset = collapseOffsetPx
collapseOffsetPx = (collapseOffsetPx - deltaPx).coerceIn(0f, collapseRangePx)
collapsedFraction = collapseOffsetPx / collapseRangePx
return -(collapseOffsetPx - previousOffset)
}
}
@Stable
class BannerScrollBehavior internal constructor(
val state: BannerScrollState,
private val canCollapse: () -> Boolean,
private val dispatchSheetRawDelta: ((Float) -> Float)?,
) {
val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val consumed = if (available.y < 0f) consumeBannerDelta(available.y) else 0f
return Offset(0f, consumed)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource,
): Offset {
val consumedByBanner = if (available.y > 0f) consumeBannerDelta(available.y) else 0f
return Offset(0f, consumedByBanner)
}
}
internal fun consumeDirectDragDelta(deltaPx: Float): Float {
if (deltaPx == 0f) return 0f
return if (deltaPx < 0f) {
val bannerConsumed = consumeBannerDelta(deltaPx)
val remaining = deltaPx - bannerConsumed
bannerConsumed + consumeSheetDelta(remaining)
} else {
val sheetConsumed = consumeSheetDelta(deltaPx)
val remaining = deltaPx - sheetConsumed
sheetConsumed + consumeBannerDelta(remaining)
}
}
private fun consumeBannerDelta(deltaPx: Float): Float {
if (deltaPx < 0f && !canCollapse()) return 0f
return state.consumeScrollDelta(deltaPx)
}
private fun consumeSheetDelta(gestureDeltaPx: Float): Float {
if (gestureDeltaPx == 0f) return 0f
val consumedRaw = dispatchSheetRawDelta?.invoke(-gestureDeltaPx) ?: 0f
return -consumedRaw
}
}
private val BannerScrollStateSaver: Saver<BannerScrollState, Float> = Saver(
save = { it.collapsedFraction },
restore = { BannerScrollState(initialCollapsedFraction = it) },
)
@Composable
private fun rememberBannerScrollState(
initialCollapsedFraction: Float = 0f,
): BannerScrollState = rememberSaveable(saver = BannerScrollStateSaver) {
BannerScrollState(initialCollapsedFraction)
}
@Composable
fun rememberBannerScrollBehavior(
state: BannerScrollState = rememberBannerScrollState(),
canCollapse: () -> Boolean = { true },
): BannerScrollBehavior = remember(state, canCollapse) {
BannerScrollBehavior(state = state, canCollapse = canCollapse, dispatchSheetRawDelta = null)
}
@Composable
fun rememberBannerScrollBehavior(
sheetLazyListState: LazyListState,
state: BannerScrollState = rememberBannerScrollState(),
canCollapse: () -> Boolean = { true },
): BannerScrollBehavior {
return remember(state, sheetLazyListState, canCollapse) {
BannerScrollBehavior(
state = state,
canCollapse = {
canCollapse() &&
(sheetLazyListState.canScrollForward || sheetLazyListState.canScrollBackward)
},
dispatchSheetRawDelta = sheetLazyListState::dispatchRawDelta,
)
}
}
object BannerScaffoldDefaults {
val SheetCornerRadius: Dp = 28.dp
@Composable
fun colors(
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
bannerContentColor: Color = MaterialTheme.colorScheme.onSurface,
) = BannerScaffoldColors(
sheetBackgroundColor = sheetBackgroundColor,
bannerContentColor = bannerContentColor,
sheetContentColor = contentColorFor(sheetBackgroundColor),
)
}
@ConsistentCopyVisibility
@Immutable
data class BannerScaffoldColors internal constructor(
val sheetBackgroundColor: Color,
val bannerContentColor: Color,
val sheetContentColor: Color,
)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BannerScaffold(
modifier: Modifier = Modifier,
title: String = "",
onBackClick: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
colors: BannerScaffoldColors = BannerScaffoldDefaults.colors(),
scrollBehavior: BannerScrollBehavior? = null,
bannerBackground: @Composable BannerScope.() -> Unit = {},
bannerContent: @Composable BannerScope.() -> Unit,
sheetContent: @Composable (PaddingValues) -> Unit,
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val density = LocalDensity.current
val isLandscape = maxWidth > maxHeight
val axisFraction = if (isLandscape) 0.5f else 0.35f
val collapsedAxisFraction = if (isLandscape) axisFraction else 0.15f
val topBarHeight = if (isLandscape) 0.dp else 64.dp
val topBarPx = with(density) { topBarHeight.roundToPx() }
val totalMainAxisPx = with(density) {
if (isLandscape) maxWidth.roundToPx() else maxHeight.roundToPx()
}
val availableMainAxisPx = (totalMainAxisPx - if (isLandscape) 0 else topBarPx).coerceAtLeast(0)
val expandedBannerPx = (availableMainAxisPx * axisFraction).roundToInt()
val collapsedBannerPx = (availableMainAxisPx * collapsedAxisFraction)
.roundToInt()
.coerceAtMost(expandedBannerPx)
val collapseFraction = if (isLandscape) 0f else scrollBehavior?.state?.collapsedFraction ?: 0f
SideEffect {
scrollBehavior?.state?.offsetLimitPx =
if (isLandscape) 0f else (expandedBannerPx - collapsedBannerPx).toFloat()
}
val bannerPx = if (isLandscape) expandedBannerPx else {
lerp(expandedBannerPx.toFloat(), collapsedBannerPx.toFloat(), collapseFraction).roundToInt()
}
val bannerSize = with(density) {
bannerPx.coerceIn(0, availableMainAxisPx).toDp()
}
val cornerRadius = BannerScaffoldDefaults.SheetCornerRadius
val cornerPx = with(density) { cornerRadius.roundToPx() }
val backgroundExtent = with(density) {
val maxPx = if (isLandscape) maxWidth.roundToPx() else maxHeight.roundToPx()
val extentPx = bannerPx + cornerPx + if (isLandscape) 0 else topBarPx
extentPx.coerceAtMost(maxPx).toDp()
}
val bannerScope = remember(collapseFraction, isLandscape, colors.bannerContentColor) {
BannerScope(collapseFraction, isLandscape, colors.bannerContentColor)
}
val scaffoldScrollModifier = if (!isLandscape && scrollBehavior != null) {
Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.scrollable(
state = rememberScrollableState { scrollBehavior.consumeDirectDragDelta(it) },
orientation = Orientation.Vertical,
)
} else {
Modifier
}
Box(Modifier.fillMaxSize().then(scaffoldScrollModifier)) {
val bannerBackgroundModifier = Modifier.align(Alignment.TopStart).then(
if (isLandscape) {
Modifier.width(backgroundExtent).fillMaxHeight()
} else {
Modifier.fillMaxWidth().height(backgroundExtent)
}
)
CompositionLocalProvider(LocalContentColor provides colors.bannerContentColor) {
Box(modifier = bannerBackgroundModifier) {
bannerScope.bannerBackground()
}
}
val bannerContentModifier = Modifier.align(Alignment.TopStart).then(
if (isLandscape) {
Modifier.width(bannerSize).fillMaxHeight()
} else {
Modifier.padding(top = topBarHeight).fillMaxWidth().height(bannerSize)
}
)
CompositionLocalProvider(LocalContentColor provides colors.bannerContentColor) {
Box(modifier = bannerContentModifier.clipToBounds()) {
bannerScope.bannerContent()
}
}
val sheetShape = if (isLandscape) {
RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)
} else {
RoundedCornerShape(topStart = cornerRadius, topEnd = cornerRadius)
}
val sheetBackgroundModifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Modifier
.clip(sheetShape)
.background(colors.sheetBackgroundColor)
} else {
Modifier.background(colors.sheetBackgroundColor, sheetShape)
}
val sheetContainerModifier = Modifier
.fillMaxSize()
.padding(
start = if (isLandscape) bannerSize else 0.dp,
top = if (isLandscape) 0.dp else topBarHeight + bannerSize,
)
CompositionLocalProvider(
LocalContentColor provides colors.sheetContentColor,
LocalOverscrollFactory provides null,
) {
Box(
modifier = sheetContainerModifier
.then(sheetBackgroundModifier)
.then(
if (isLandscape) {
Modifier.windowInsetsPadding(
WindowInsets.systemBars.only(
WindowInsetsSides.End + WindowInsetsSides.Vertical,
)
)
} else {
Modifier
}
)
) {
val insetPadding = if (isLandscape) {
PaddingValues(start = cornerRadius)
} else {
PaddingValues(top = cornerRadius)
}
sheetContent(insetPadding)
}
}
TopAppBar(
modifier = Modifier
.align(Alignment.TopStart)
.then(if (isLandscape) Modifier.width(bannerSize) else Modifier.fillMaxWidth()),
title = { Text(title) },
navigationIcon = {
if (onBackClick != null) {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
}
},
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = colors.bannerContentColor,
navigationIconContentColor = colors.bannerContentColor,
actionIconContentColor = colors.bannerContentColor,
),
windowInsets = WindowInsets.systemBars.only(
if (isLandscape) {
WindowInsetsSides.Top + WindowInsetsSides.Start
} else {
WindowInsetsSides.Top + WindowInsetsSides.Horizontal
}
),
)
}
}
}

View File

@@ -1,7 +1,6 @@
package app.revanced.manager.ui.component.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
@@ -38,12 +37,26 @@ fun BooleanItem(
onValueChange: (Boolean) -> Unit,
@StringRes headline: Int,
@StringRes description: Int
) = BooleanItem(
modifier = modifier,
value = value,
onValueChange = onValueChange,
headline = headline,
description = stringResource(description)
)
@Composable
fun BooleanItem(
modifier: Modifier = Modifier,
value: Boolean,
onValueChange: (Boolean) -> Unit,
@StringRes headline: Int,
description: String
) = SettingsListItem(
modifier = Modifier
.clickable { onValueChange(!value) }
.then(modifier),
modifier = modifier,
headlineContent = stringResource(headline),
supportingContent = stringResource(description),
supportingContent = description,
onClick = { onValueChange(!value) },
trailingContent = {
HapticSwitch(
checked = value,

View File

@@ -0,0 +1,45 @@
package app.revanced.manager.ui.component.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun ExpressiveListIcon(
icon: ImageVector,
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.secondaryContainer,
iconColor: Color = MaterialTheme.colorScheme.onSecondaryContainer,
size: Dp = 42.dp,
iconSize: Dp = 24.dp,
contentDescription: String? = null,
) {
Surface(
modifier = modifier.size(size),
shape = CircleShape,
color = containerColor
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(iconSize),
tint = iconColor
)
}
}
}

View File

@@ -1,11 +1,12 @@
package app.revanced.manager.ui.component.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -26,7 +27,8 @@ fun IntegerItem(
preference: Preference<Int>,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
@StringRes headline: Int,
@StringRes description: Int
@StringRes description: Int,
unit: String? = null,
) {
val value by preference.getAsState()
@@ -35,42 +37,48 @@ fun IntegerItem(
value = value,
onValueChange = { coroutineScope.launch { preference.update(it) } },
headline = headline,
description = description
description = description,
unit = unit
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun IntegerItem(
modifier: Modifier = Modifier,
value: Int,
onValueChange: (Int) -> Unit,
@StringRes headline: Int,
@StringRes description: Int
@StringRes description: Int,
unit: String? = null,
) {
var dialogOpen by rememberSaveable {
mutableStateOf(false)
}
if (dialogOpen) {
IntInputDialog(current = value, name = stringResource(headline)) { new ->
IntInputDialog(
current = value,
unit = unit,
name = stringResource(headline)
) { new ->
dialogOpen = false
new?.let(onValueChange)
}
}
SettingsListItem(
modifier = Modifier
.clickable { dialogOpen = true }
.then(modifier),
modifier = modifier,
headlineContent = stringResource(headline),
supportingContent = stringResource(description),
trailingContent = {
IconButton(onClick = { dialogOpen = true }) {
IconButton(onClick = { dialogOpen = true }, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.Outlined.Edit,
contentDescription = stringResource(R.string.edit)
)
}
}
},
onClick = { dialogOpen = true }
)
}

View File

@@ -24,18 +24,40 @@ fun SafeguardBooleanItem(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
@StringRes headline: Int,
@StringRes description: Int,
@StringRes confirmationText: Int
@StringRes confirmationText: Int,
onValueChange: ((Boolean) -> Unit)? = null
) = SafeguardBooleanItem(
modifier = modifier,
preference = preference,
coroutineScope = coroutineScope,
headline = headline,
description = stringResource(description),
confirmationText = confirmationText,
onValueChange = onValueChange
)
@Composable
fun SafeguardBooleanItem(
modifier: Modifier = Modifier,
preference: Preference<Boolean>,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
@StringRes headline: Int,
description: String,
@StringRes confirmationText: Int,
onValueChange: ((Boolean) -> Unit)? = null
) {
val value by preference.getAsState()
var showSafeguardWarning by rememberSaveable {
mutableStateOf(false)
}
val update = onValueChange ?: { coroutineScope.launch { preference.update(it) } }
if (showSafeguardWarning) {
ConfirmDialog(
onDismiss = { showSafeguardWarning = false },
onConfirm = {
coroutineScope.launch { preference.update(!value) }
update(!value)
showSafeguardWarning = false
},
title = stringResource(id = R.string.warning),
@@ -51,7 +73,7 @@ fun SafeguardBooleanItem(
if (it != preference.default) {
showSafeguardWarning = true
} else {
coroutineScope.launch { preference.update(it) }
update(it)
}
},
headline = headline,

View File

@@ -1,107 +1,126 @@
package app.revanced.manager.ui.component.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ListItem
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import app.revanced.manager.util.withHapticFeedback
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsListItem(
headlineContent: String,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onLongClickLabel: String? = null,
overlineContent: @Composable (() -> Unit)? = null,
supportingContent: String? = null,
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation,
) = SettingsListItem(
headlineContent = {
Text(
text = headlineContent,
style = MaterialTheme.typography.titleLarge
)
},
modifier = modifier,
overlineContent = overlineContent,
supportingContent = supportingContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
colors = colors,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation
)
) {
val shapes = ListItemDefaults.segmentedShapes(index = 0, count = 1)
val colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
SegmentedListItem(
onClick = onClick?.withHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK) ?: { }.withHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK),
onLongClick = onLongClick,
onLongClickLabel = onLongClickLabel,
shapes = shapes,
colors = colors,
modifier = modifier,
overlineContent = overlineContent,
leadingContent = leadingContent,
trailingContent = trailingContent?.let {
{
Box(modifier = Modifier.padding(start = 4.dp)) {
trailingContent()
}
}
},
supportingContent = supportingContent?.let { { Text(it) } },
verticalAlignment = Alignment.CenterVertically,
) {
Text(headlineContent)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsListItem(
headlineContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onLongClickLabel: String? = null,
overlineContent: @Composable (() -> Unit)? = null,
supportingContent: String? = null,
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation,
) = ListItem(
headlineContent = headlineContent,
modifier = modifier.then(Modifier.padding(horizontal = 8.dp)),
overlineContent = overlineContent,
supportingContent = {
if (supportingContent != null)
Text(
text = supportingContent,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
},
leadingContent = leadingContent,
trailingContent = trailingContent,
colors = colors,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation
)
) {
val shapes = ListItemDefaults.segmentedShapes(index = 0, count = 1)
val colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
SegmentedListItem(
onClick = onClick?.withHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK) ?: { }.withHapticFeedback(HapticFeedbackConstantsCompat.CLOCK_TICK),
onLongClick = onLongClick,
onLongClickLabel = onLongClickLabel,
shapes = shapes,
colors = colors,
modifier = modifier,
overlineContent = overlineContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
supportingContent = supportingContent?.let { { Text(it) } },
content = headlineContent
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExpandableSettingListItem(
fun ExpandableSettingsListItem(
headlineContent: String,
supportingContent: String,
expandableContent: @Composable () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
var expanded by rememberSaveable { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
) {
Column(modifier = Modifier.fillMaxWidth()) {
SettingsListItem(
modifier = Modifier
.clickable{ expanded = !expanded },
modifier = Modifier.semantics {
stateDescription = if (expanded) "Expanded" else "Collapsed"
},
headlineContent = headlineContent,
supportingContent = supportingContent,
onClick = { expanded = !expanded },
trailingContent = {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
@@ -110,17 +129,16 @@ fun ExpandableSettingListItem(
}
)
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(
visible = expanded,
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp, start = 16.dp, end = 16.dp)
.animateContentSize(
animationSpec = tween(
durationMillis = 500,
easing = FastOutSlowInEasing
)
)
.padding(top = ListItemDefaults.SegmentedGap),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
expandableContent()
}

View File

@@ -0,0 +1,145 @@
package app.revanced.manager.ui.component.settings
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.outlined.BrightnessAuto
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.LightMode
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButtonColors
import androidx.compose.material3.IconToggleButtonShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.withHapticFeedback
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ThemeSelector(
currentTheme: Theme,
onThemeSelected: (Theme) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
color = animateColorAsState(MaterialTheme.colorScheme.surfaceContainerLow, MaterialTheme.motionScheme.defaultEffectsSpec(), "surfaceContainerLow").value,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
ThemeOption(
icon = Icons.Outlined.BrightnessAuto,
selectedIcon = Icons.Filled.BrightnessAuto,
label = stringResource(R.string.system),
isSelected = currentTheme == Theme.SYSTEM,
onClick = { onThemeSelected(Theme.SYSTEM) },
modifier = Modifier.weight(1f)
)
ThemeOption(
icon = Icons.Outlined.LightMode,
selectedIcon = Icons.Filled.LightMode,
label = stringResource(R.string.light),
isSelected = currentTheme == Theme.LIGHT,
onClick = { onThemeSelected(Theme.LIGHT) },
modifier = Modifier.weight(1f)
)
ThemeOption(
icon = Icons.Outlined.DarkMode,
selectedIcon = Icons.Filled.DarkMode,
label = stringResource(R.string.dark),
isSelected = currentTheme == Theme.DARK,
onClick = { onThemeSelected(Theme.DARK) },
modifier = Modifier.weight(1f)
)
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ThemeOption(
icon: ImageVector,
selectedIcon: ImageVector,
label: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.clickable(
onClick = onClick.withHapticFeedback(HapticFeedbackConstantsCompat.VIRTUAL_KEY),
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = null
)
.padding(vertical = 4.dp)
) {
FilledTonalIconToggleButton(
checked = isSelected,
onCheckedChange = { onClick() },
modifier = Modifier.size(56.dp),
shapes = IconToggleButtonShapes(
shape = CircleShape,
pressedShape = RoundedCornerShape(16.dp),
checkedShape = RoundedCornerShape(16.dp)
),
colors = IconToggleButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer,
checkedContentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
) {
Icon(
imageVector = if (isSelected) selectedIcon else icon,
contentDescription = label,
modifier = Modifier.size(28.dp)
)
}
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = if (isSelected) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}

View File

@@ -11,6 +11,9 @@ import kotlinx.serialization.Serializable
interface ComplexParameter<T : Parcelable>
@Serializable
object Onboarding
@Serializable
object Dashboard
@@ -20,6 +23,9 @@ object AppSelector
@Serializable
data class InstalledApplicationInfo(val packageName: String)
@Serializable
data class BundleInformation(val uid: Int)
@Serializable
data class Update(val downloadOnScreenEntry: Boolean = false)
@@ -83,6 +89,9 @@ object Settings {
@Serializable
data object Downloads : Destination
@Serializable
data class DownloadersInfo(val packageName: String) : Destination
@Serializable
data object ImportExport : Destination

View File

@@ -4,7 +4,9 @@ import android.annotation.SuppressLint
import android.view.MotionEvent
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -14,6 +16,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -31,6 +34,8 @@ import androidx.core.view.children
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.util.relativeTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.intellij.lang.annotations.Language
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -58,11 +63,11 @@ fun AnnouncementScreen(
)
},
subtitle = {
val createDate = announcement.createdAt.relativeTime(LocalContext.current)
val createDate = announcement.createdAt.toLocalDateTime(TimeZone.UTC).relativeTime(LocalContext.current)
Text("$createDate · ${announcement.author}")
},
navigationIcon = {
IconButton(onClick = onBackClick) {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
@@ -73,37 +78,46 @@ fun AnnouncementScreen(
)
}
) { paddingValues ->
AndroidView(
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(horizontal = 10.dp),
factory = {
val webView = WebView(it).apply {
setBackgroundColor(0)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
isLongClickable = false
setOnLongClickListener { true }
isHapticFeedbackEnabled = false
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
.padding(paddingValues)
) {
AnnouncementTag(
tags = announcement.tags,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 8.dp)
)
AndroidView(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
factory = {
val webView = WebView(it).apply {
setBackgroundColor(0)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
isLongClickable = false
setOnLongClickListener { true }
isHapticFeedbackEnabled = false
// Disable WebView's internal scrolling
@SuppressLint("ClickableViewAccessibility")
setOnTouchListener { _, event ->
event.action == MotionEvent.ACTION_MOVE
// Disable WebView's internal scrolling
@SuppressLint("ClickableViewAccessibility")
setOnTouchListener { _, event ->
event.action == MotionEvent.ACTION_MOVE
}
}
}
FrameLayout(it).apply {
addView(webView)
}
},
update = {
val webView = it.children.first() as WebView
@Language("HTML")
val style = """
FrameLayout(it).apply {
addView(webView)
}
},
update = {
val webView = it.children.first() as WebView
@Language("HTML")
val style = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -121,13 +135,14 @@ fun AnnouncementScreen(
</body>
</html>
""".trimIndent()
webView.loadData(style, "text/html", "UTF-8")
},
onRelease = {
val webView = it.children.first() as WebView
webView.destroy()
}
)
webView.loadData(style, "text/html", "UTF-8")
},
onRelease = {
val webView = it.children.first() as WebView
webView.destroy()
}
)
}
}
}

View File

@@ -1,6 +1,8 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.background
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -10,22 +12,23 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.FilterAlt
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material3.Badge
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
@@ -36,20 +39,27 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.AnnouncementsViewModel
import app.revanced.manager.util.relativeTime
import app.revanced.manager.util.transparentListItemColors
import app.revanced.manager.util.withHapticFeedback
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -61,18 +71,16 @@ fun AnnouncementsScreen(
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
var archivedExpanded by rememberSaveable { mutableStateOf(false) }
val tags by vm.tags.collectAsStateWithLifecycle(null)
val selectedTags by vm.selectedTags.getAsState()
val showArchived by vm.showArchived.collectAsStateWithLifecycle()
val announcements by vm.announcements.collectAsStateWithLifecycle(emptyList())
val announcementSections by vm.announcementSections.collectAsStateWithLifecycle(null)
if (showFilterSheet) {
FilterBottomSheet(
onDismissRequest = { showFilterSheet = false },
tags = tags.orEmpty(),
selectedTags = selectedTags,
showArchived = showArchived,
onShowArchivedChange = { vm.showArchived.value = it },
onReset = vm::resetTagSelection,
changeSelection = vm::changeTagSelection
)
@@ -87,7 +95,7 @@ fun AnnouncementsScreen(
if (tags != null) {
IconButton(onClick = { showFilterSheet = true }) {
Icon(
imageVector = Icons.Outlined.FilterAlt,
imageVector = Icons.Filled.FilterAlt,
contentDescription = stringResource(R.string.announcements_filter_tag)
)
}
@@ -103,11 +111,15 @@ fun AnnouncementsScreen(
.fillMaxSize()
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection),
verticalArrangement = if (announcements.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
verticalArrangement = if (announcementSections?.isEmpty != false) {
Arrangement.Center
} else {
Arrangement.spacedBy(8.dp)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
announcements?.let { announcements ->
if (announcements.isEmpty()) {
announcementSections?.let { sections ->
if (sections.isEmpty) {
item {
Text(
text = stringResource(id = R.string.no_announcements_found),
@@ -115,32 +127,60 @@ fun AnnouncementsScreen(
)
}
} else {
itemsIndexed(
items = announcements,
key = { _, announcement ->
announcement.id
val activeAnnouncements = sections.activeAnnouncements
val archivedAnnouncements = sections.archivedAnnouncements
if (activeAnnouncements.isNotEmpty()) {
item {
ListSection {
activeAnnouncements.forEach { announcement ->
AnnouncementListItem(
onClick = {
vm.markAnnouncementRead(announcement.id)
onAnnouncementClick(announcement)
},
title = announcement.title,
date = announcement.createdAt.toLocalDateTime(TimeZone.UTC).relativeTime(LocalContext.current),
author = announcement.author,
tags = announcement.tags,
unread = announcement.id !in readAnnouncements,
archived = false
)
}
}
}
) { i, announcement ->
if (i != 0) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth()
}
if (archivedAnnouncements.isNotEmpty()) {
item {
ArchivedAnnouncementsHeader(
expanded = archivedExpanded,
onToggle = { archivedExpanded = !archivedExpanded },
modifier = Modifier
.fillMaxWidth()
)
}
}
AnnouncementCard(
modifier = Modifier
.fillMaxWidth(),
onClick = {
vm.markAnnouncementRead(announcement.id)
onAnnouncementClick(announcement)
},
title = announcement.title,
date = announcement.createdAt.relativeTime(LocalContext.current),
author = announcement.author,
content = announcement.content,
unread = announcement.id !in readAnnouncements,
archived = announcement.isArchived
)
if (archivedAnnouncements.isNotEmpty() && archivedExpanded) {
item {
ListSection {
archivedAnnouncements.forEach { announcement ->
AnnouncementListItem(
onClick = {
vm.markAnnouncementRead(announcement.id)
onAnnouncementClick(announcement)
},
title = announcement.title,
date = announcement.createdAt.toLocalDateTime(TimeZone.UTC).relativeTime(LocalContext.current),
author = announcement.author,
tags = announcement.tags,
unread = announcement.id !in readAnnouncements,
archived = true
)
}
}
}
}
}
} ?: item {
@@ -155,14 +195,12 @@ fun AnnouncementsScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun FilterBottomSheet(
onDismissRequest: () -> Unit,
tags: Set<String>,
selectedTags: Set<String>,
showArchived: Boolean,
onShowArchivedChange: (Boolean) -> Unit,
onReset: () -> Unit,
changeSelection: (String) -> Unit
) {
@@ -170,15 +208,19 @@ private fun FilterBottomSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.announcements_filter_tag),
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp)
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tags.forEach { tag ->
@@ -186,25 +228,19 @@ private fun FilterBottomSheet(
selected = tag in selectedTags,
onClick = {
changeSelection(tag)
},
}.withHapticFeedback(HapticFeedbackConstantsCompat.CONFIRM),
label = { Text(tag) }
)
}
}
ListItem(
modifier = Modifier.clickable(onClick = { onShowArchivedChange(!showArchived) }),
headlineContent = { Text(text = stringResource(R.string.announcements_show_archived)) },
trailingContent = {
Switch(
checked = showArchived,
onCheckedChange = onShowArchivedChange
)
},
colors = transparentListItemColors
)
TextButton(modifier = Modifier.align(Alignment.End), onClick = onReset) {
TextButton(
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 16.dp),
onClick = onReset,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.reset))
}
}
@@ -212,32 +248,66 @@ private fun FilterBottomSheet(
}
@Composable
private fun AnnouncementCard(
modifier: Modifier = Modifier,
private fun ArchivedAnnouncementsHeader(
expanded: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
val rotation by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
label = "archivedChevronRotation"
)
Row(
modifier = modifier
.padding(horizontal = 8.dp).clip(MaterialTheme.shapes.small).clickable(onClick = onToggle).padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.History,
contentDescription = null,
modifier = Modifier
.size(24.dp)
.padding(end = 8.dp)
)
Text(
text = stringResource(R.string.announcements_show_archived),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = if (expanded) stringResource(R.string.collapse_content) else stringResource(R.string.expand_content),
modifier = Modifier.rotate(rotation)
)
}
}
@Composable
private fun AnnouncementListItem(
onClick: () -> Unit,
title: String,
date: String,
author: String,
content: String,
tags: List<String>,
unread: Boolean,
archived: Boolean
) {
Column(
modifier = modifier
.clickable(onClick = onClick)
.background(if (unread) MaterialTheme.colorScheme.surfaceContainerLow else MaterialTheme.colorScheme.surface)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
SettingsListItem(
onClick = onClick,
headlineContent = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 1500,
initialDelayMillis = 2500,
spacing = MarqueeSpacing.fractionOfContainer(1f / 5f),
velocity = 55.dp,
),
style = MaterialTheme.typography.titleMedium,
fontWeight = if (unread) FontWeight.ExtraBold else null
fontWeight = if (unread) FontWeight.ExtraBold else null,
maxLines = 1
)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -259,69 +329,49 @@ private fun AnnouncementCard(
Badge(modifier = Modifier.size(6.dp))
}
}
AnnouncementTag(
tags = tags,
modifier = Modifier.fillMaxWidth()
)
}
Icon(Icons.Default.ChevronRight, contentDescription = null)
},
trailingContent = {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = stringResource(R.string.view_announcement)
)
}
// TODO add announcement summary
// val textColor = MaterialTheme.colorScheme.onSurface
// val linkColor = MaterialTheme.colorScheme.primary
// AndroidView(
// factory = {
// WebView(it).apply {
// setBackgroundColor(0)
// isVerticalScrollBarEnabled = false
// isHorizontalScrollBarEnabled = false
// isLongClickable = false
// setOnLongClickListener { true }
// isHapticFeedbackEnabled = false
//
// // Disable WebView's internal scrolling
// @SuppressLint("ClickableViewAccessibility")
// setOnTouchListener { _, event ->
// event.action == MotionEvent.ACTION_MOVE
// }
// }
// },
// update = {
// @Language("HTML")
// val body = """
// <html>
// <head>
// <meta name="viewport" content="width=device-width, initial-scale=1" />
// <style>
// * {
// font-size: 12px;
// font-weight: normal;
// }
// body {
// margin: 0;
// padding: 0;
// color: ${textColor.toCss()};
// overflow: hidden;
// display: -webkit-box;
// -webkit-box-orient: vertical;
// -webkit-line-clamp: 3;
// text-overflow: ellipsis;
// }
// a {
// color: ${linkColor.toCss()};
// }
// </style>
// </head>
// <body>
// $content
// </body>
// </html>
// """.trimIndent()
//
// it.loadData(body, "text/html", "UTF-8")
// },
// onReset = {},
// onRelease = { it.destroy() }
// )
}
)
}
//private fun Color.toCss(): String {
// return "rgba(${red * 255f}, ${green * 255f}, ${blue * 255f}, $alpha)"
//}
//}
@Composable
fun AnnouncementTag(
tags: List<String>,
modifier: Modifier = Modifier
) {
if (tags.isEmpty()) return
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
tags.forEach { tag ->
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface
) {
Text(
text = tag,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}

View File

@@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -51,7 +53,7 @@ import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.transparentListItemColors
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AppSelectorScreen(
onSelect: (String) -> Unit,
@@ -132,7 +134,7 @@ fun AppSelectorScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.Search,
imageVector = Icons.Filled.Search,
contentDescription = stringResource(R.string.search),
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
@@ -157,8 +159,8 @@ fun AppSelectorScreen(
scrollBehavior = scrollBehavior,
onBackClick = onBackClick,
actions = {
IconButton(onClick = { search = true }) {
Icon(Icons.Outlined.Search, stringResource(R.string.search))
IconButton(onClick = { search = true }, shapes = IconButtonDefaults.shapes()) {
Icon(Icons.Filled.Search, stringResource(R.string.search))
}
}
)

View File

@@ -0,0 +1,414 @@
package app.revanced.manager.ui.screen
import android.webkit.URLUtil.isValidUrl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.ui.component.settings.SafeguardBooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.component.bundle.BundlePatchesDialog
import app.revanced.manager.ui.component.haptics.HapticSwitch
import app.revanced.manager.ui.viewmodel.BundleInformationViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BundleInformationScreen(
onBackClick: () -> Unit,
viewModel: BundleInformationViewModel
) {
val src = viewModel.bundle ?: return
val patchCount = viewModel.patchCount
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
val isLocal = src is LocalPatchBundle
val bundleManifestAttributes = src.patchBundle?.manifestAttributes
val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint }
?: (null to null)
val subtitleAuthor = bundleManifestAttributes?.author?.let {
stringResource(R.string.bundle_information_by_author, it)
}
val subtitleVersion = bundleManifestAttributes?.version?.let { "v$it" }
val contentScrollState = rememberScrollState()
val isContentScrollable by remember { derivedStateOf { contentScrollState.maxValue > 0 } }
if (viewCurrentBundlePatches) {
BundlePatchesDialog(
src = src,
onDismissRequest = { viewCurrentBundlePatches = false }
)
}
if (showDeleteConfirmationDialog) {
ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = {
viewModel.delete()
onBackClick()
},
title = stringResource(R.string.delete),
description = stringResource(R.string.patches_delete_single_dialog_description, src.name),
icon = Icons.Outlined.Delete
)
}
val scrollBehavior = if (isContentScrollable) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
} else {
null
}
Scaffold(
topBar = {
MediumFlexibleTopAppBar(
title = { Text(src.name) },
subtitle = if (subtitleAuthor != null || subtitleVersion != null) {
{
Row(verticalAlignment = Alignment.CenterVertically) {
subtitleAuthor?.let { Text(it) }
if (subtitleAuthor != null && subtitleVersion != null) {
Spacer(modifier = Modifier.width(8.dp))
Spacer(
modifier = Modifier
.size(3.dp)
.background(
color = LocalContentColor.current,
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(8.dp))
}
subtitleVersion?.let { Text(it) }
}
}
} else {
null
},
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
if (!src.isDefault) {
IconButton(onClick = { showDeleteConfirmationDialog = true }, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.Filled.Delete,
stringResource(R.string.delete)
)
}
}
val hasNetwork = remember { viewModel.networkInfo.isConnected() }
if (!isLocal && hasNetwork) {
IconButton(onClick = viewModel::refresh, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.Filled.Update,
stringResource(R.string.refresh)
)
}
}
},
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.then(
scrollBehavior?.let { Modifier.nestedScroll(it.nestedScrollConnection) } ?: Modifier
),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues),
state = contentScrollState,
) {
bundleManifestAttributes?.description?.let { description ->
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp)
)
}
Column(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
) {
bundleManifestAttributes?.website?.let { website ->
TagValue(
icon = Icons.Outlined.Language,
title = stringResource(R.string.website),
value = website,
uri = website
)
}
bundleManifestAttributes?.contact?.let { contact ->
TagValue(
icon = Icons.AutoMirrored.Outlined.Send,
title = stringResource(R.string.contact),
value = contact,
uri = if (contact.startsWith("mailto:")) contact else "mailto:$contact"
)
}
bundleManifestAttributes?.source?.let { source ->
TagValue(
icon = Icons.Outlined.Source,
title = stringResource(R.string.repository),
value = source
)
}
bundleManifestAttributes?.license?.let { license ->
TagValue(
icon = Icons.Outlined.Gavel,
title = stringResource(R.string.license),
value = license
)
}
}
ListSection {
if (autoUpdate != null) {
SettingsListItem(
headlineContent = stringResource(R.string.auto_update),
supportingContent = stringResource(R.string.auto_update_description),
trailingContent = {
HapticSwitch(
checked = autoUpdate,
onCheckedChange = viewModel::setAutoUpdate
)
},
onClick = { viewModel.setAutoUpdate(!autoUpdate) }
)
}
if (src.isDefault) {
SafeguardBooleanItem(
preference = viewModel.prefs.usePatchesPrereleases,
headline = R.string.patches_prereleases,
description = stringResource(R.string.patches_prereleases_description, src.name),
confirmationText = R.string.prereleases_warning,
onValueChange = viewModel::updateUsePrereleases
)
}
endpoint?.takeUnless { src.isDefault }?.let { url ->
var showUrlInputDialog by rememberSaveable { mutableStateOf(false) }
if (showUrlInputDialog) {
TextInputDialog(
initial = url,
title = stringResource(R.string.patches_url),
onDismissRequest = { showUrlInputDialog = false },
onConfirm = {
showUrlInputDialog = false
// TODO: Not implemented
},
validator = {
if (it.isEmpty()) return@TextInputDialog false
isValidUrl(it)
}
)
}
SettingsListItem(
headlineContent = stringResource(R.string.patches_url),
supportingContent = url.ifEmpty {
stringResource(R.string.field_not_set)
},
onClick = null
)
}
val patchesClickable = patchCount > 0
SettingsListItem(
headlineContent = stringResource(R.string.patches),
supportingContent = stringResource(R.string.view_patches),
onClick = if (patchesClickable) {
{ viewCurrentBundlePatches = true }
} else null,
trailingContent = if (patchesClickable) {
{
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
stringResource(R.string.patches)
)
}
} else null
)
src.error?.let {
var showDialog by rememberSaveable { mutableStateOf(false) }
if (showDialog) {
ExceptionViewerDialog(
onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() }
)
}
SettingsListItem(
headlineContent = stringResource(R.string.patches_error),
supportingContent = stringResource(R.string.patches_error_description),
trailingContent = {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
null
)
},
onClick = { showDialog = true }
)
}
if (src.state is PatchBundleSource.State.Missing && !isLocal) {
SettingsListItem(
headlineContent = stringResource(R.string.patches_error),
supportingContent = stringResource(R.string.patches_not_downloaded),
onClick = viewModel::refresh
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun TagValue(
icon: ImageVector,
title: String,
value: String,
uri: String? = null
) {
val uriHandler = LocalUriHandler.current
val onClick: (() -> Unit)? = uri?.let { targetUri ->
{
try {
uriHandler.openUri(targetUri)
} catch (_: Exception) {}
}
}
val buttonText = value
.removePrefix("https://")
.removePrefix("http://")
.removePrefix("mailto:")
.removeSuffix("/")
Row(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.padding(end = if (onClick != null) 0.dp else 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.width(16.dp))
if (onClick != null) {
TextButton(onClick = onClick, shapes = ButtonDefaults.shapes()) {
Text(
text = buttonText,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
} else {
Text(
text = buttonText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End
)
}
}
}

View File

@@ -1,8 +1,11 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
@@ -11,6 +14,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.EmptyState
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.bundle.BundleItem
import app.revanced.manager.ui.viewmodel.BundleListViewModel
@@ -23,7 +28,8 @@ import org.koin.androidx.compose.koinViewModel
fun BundleListScreen(
viewModel: BundleListViewModel = koinViewModel(),
eventsFlow: Flow<BundleListViewModel.Event>,
setSelectedSourceCount: (Int) -> Unit
setSelectedSourceCount: (Int) -> Unit,
onBundleClick: (Int) -> Unit
) {
val patchCounts by viewModel.patchCounts.collectAsStateWithLifecycle(emptyMap())
val sources by viewModel.sources.collectAsStateWithLifecycle(emptyList())
@@ -39,38 +45,43 @@ fun BundleListScreen(
onRefresh = viewModel::refresh,
isRefreshing = viewModel.isRefreshing
) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
items(
sources,
key = { it.uid }
) { source ->
BundleItem(
src = source,
patchCount = patchCounts[source.uid] ?: 0,
onDelete = {
viewModel.delete(source)
},
onUpdate = {
viewModel.update(source)
},
selectable = viewModel.selectedSources.size > 0,
onSelect = {
viewModel.selectedSources.add(source.uid)
},
isBundleSelected = source.uid in viewModel.selectedSources,
toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) {
viewModel.selectedSources.add(source.uid)
} else {
viewModel.selectedSources.remove(source.uid)
}
}
if (sources.isEmpty()) {
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = Icons.Outlined.Source,
title = R.string.no_patches_found,
description = R.string.no_patches_description
)
}
} else {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
items(
sources,
key = { it.uid }
) { source ->
BundleItem(
src = source,
patchCount = patchCounts[source.uid] ?: 0,
selectable = viewModel.selectedSources.size > 0,
onSelect = {
viewModel.selectedSources.add(source.uid)
},
isBundleSelected = source.uid in viewModel.selectedSources,
toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) {
viewModel.selectedSources.add(source.uid)
} else {
viewModel.selectedSources.remove(source.uid)
}
},
onClick = { onBundleClick(source.uid) }
)
}
}
}
}
}

View File

@@ -7,41 +7,58 @@ import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -52,29 +69,36 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.component.AvailableUpdateDialog
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.PillTab
import app.revanced.manager.ui.component.PillTabBar
import app.revanced.manager.ui.component.bundle.BundleTopBar
import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
import app.revanced.manager.ui.component.haptics.HapticFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.viewmodel.DashboardViewModel
import app.revanced.manager.ui.viewmodel.DownloaderUpdateState
import app.revanced.manager.util.RequestInstallAppsContract
import app.revanced.manager.util.toast
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@@ -87,7 +111,7 @@ enum class DashboardPage(
}
@SuppressLint("BatteryLife")
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DashboardScreen(
vm: DashboardViewModel = koinViewModel(),
@@ -97,7 +121,8 @@ fun DashboardScreen(
onAnnouncementsClick: () -> Unit,
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
onDownloaderClick: () -> Unit,
onAppClick: (String) -> Unit
onAppClick: (String) -> Unit,
onBundleClick: (Int) -> Unit
) {
var selectedSourceCount by rememberSaveable { mutableIntStateOf(0) }
val bundlesSelectable by remember { derivedStateOf { selectedSourceCount > 0 } }
@@ -107,6 +132,9 @@ fun DashboardScreen(
)
val androidContext = LocalContext.current
val resources = LocalResources.current
val logoPainter = rememberDrawablePainter(drawable = remember(resources) {
AppCompatResources.getDrawable(androidContext, R.drawable.ic_logo_ring)
})
val composableScope = rememberCoroutineScope()
val pagerState = rememberPagerState(
initialPage = DashboardPage.DASHBOARD.ordinal,
@@ -117,9 +145,6 @@ fun DashboardScreen(
if (pagerState.currentPage != DashboardPage.BUNDLES.ordinal) vm.cancelSourceSelection()
}
val firstLaunch by vm.prefs.firstLaunch.getAsState()
if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
var showAddBundleDialog by rememberSaveable { mutableStateOf(false) }
if (showAddBundleDialog) {
ImportPatchBundleDialog(
@@ -136,15 +161,98 @@ fun DashboardScreen(
}
var showUpdateDialog by rememberSaveable { mutableStateOf(true) }
val managerAutoUpdates by vm.prefs.managerAutoUpdates.getAsState()
val showManagerUpdateDialogOnLaunch by vm.prefs.showManagerUpdateDialogOnLaunch.getAsState()
val availableUpdate = vm.updatedManagerVersion
val availableUpdate by vm.availableManagerUpdate.collectAsStateWithLifecycle()
if (showUpdateDialog && showManagerUpdateDialogOnLaunch && availableUpdate != null) {
if (managerAutoUpdates && showUpdateDialog && showManagerUpdateDialogOnLaunch && availableUpdate != null) {
AvailableUpdateDialog(
onDismiss = { showUpdateDialog = false },
setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
onConfirm = onUpdateClick,
newVersion = availableUpdate
newVersion = availableUpdate!!
)
}
val downloaderUpdate = vm.availableDownloaderUpdate
val downloaderUpdateState = vm.downloaderUpdateState
if (downloaderUpdate != null || downloaderUpdateState == DownloaderUpdateState.DOWNLOADING || downloaderUpdateState == DownloaderUpdateState.INSTALLING) {
AlertDialogExtended(
onDismissRequest = {
if (downloaderUpdateState != DownloaderUpdateState.DOWNLOADING && downloaderUpdateState != DownloaderUpdateState.INSTALLING) {
vm.dismissDownloaderUpdate()
}
},
confirmButton = {
when (downloaderUpdateState) {
DownloaderUpdateState.IDLE -> {
TextButton(onClick = vm::downloadAndInstallDownloaderUpdate, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.update))
}
}
DownloaderUpdateState.FAILED -> {
TextButton(onClick = vm::downloadAndInstallDownloaderUpdate, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.retry))
}
}
else -> {}
}
},
dismissButton = {
if (downloaderUpdateState != DownloaderUpdateState.DOWNLOADING && downloaderUpdateState != DownloaderUpdateState.INSTALLING) {
TextButton(onClick = vm::dismissDownloaderUpdate, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.dismiss))
}
}
},
icon = {
Icon(imageVector = Icons.Outlined.Download, contentDescription = null)
},
title = {
Text(stringResource(R.string.downloader_update_available))
},
text = {
Column {
Text(
stringResource(
R.string.downloader_update_available_description,
downloaderUpdate?.version.orEmpty()
)
)
if (downloaderUpdateState == DownloaderUpdateState.DOWNLOADING) {
Spacer(
modifier = Modifier.height(16.dp)
)
LinearWavyProgressIndicator(
progress = { vm.downloaderUpdateProgress },
modifier = Modifier.fillMaxWidth()
)
}
if (downloaderUpdateState == DownloaderUpdateState.INSTALLING) {
Spacer(
modifier = Modifier.height(16.dp)
)
LinearWavyProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
Spacer(
modifier = Modifier.height(8.dp)
)
Text(
stringResource(R.string.api_downloader_installing),
)
}
if (downloaderUpdateState == DownloaderUpdateState.FAILED) {
Spacer(
modifier = Modifier.height(8.dp)
)
Text(
stringResource(R.string.api_downloader_failed),
color = MaterialTheme.colorScheme.error
)
}
}
}
)
}
@@ -174,57 +282,110 @@ fun DashboardScreen(
)
}
Scaffold(
topBar = {
if (bundlesSelectable) {
BundleTopBar(
title = stringResource(R.string.patches_selected, selectedSourceCount),
onBackClick = vm::cancelSourceSelection,
backIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.back)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface
) {
Box(modifier = Modifier.fillMaxSize()) {
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = Modifier
.fillMaxWidth()
.height(statusBarHeight + 96.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
Color.Transparent
)
)
},
actions = {
IconButton(
onClick = {
showDeleteConfirmationDialog = true
}
) {
Icon(
Icons.Outlined.DeleteOutline,
stringResource(R.string.delete)
)
)
val navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
if (navBarHeight > 0.dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(navBarHeight)
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
MaterialTheme.colorScheme.primary.copy(alpha = 0.05f)
)
)
}
IconButton(
onClick = vm::updateSources
) {
Icon(
Icons.Outlined.Refresh,
stringResource(R.string.refresh)
)
}
}
)
)
} else {
AppTopBar(
title = stringResource(R.string.app_name),
actions = {
if (!vm.updatedManagerVersion.isNullOrEmpty()) {
}
Scaffold(
topBar = {
if (bundlesSelectable) {
BundleTopBar(
title = stringResource(R.string.patches_selected, selectedSourceCount),
onBackClick = vm::cancelSourceSelection,
backIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.back)
)
},
actions = {
IconButton(
onClick = onUpdateClick,
onClick = {
showDeleteConfirmationDialog = true
},
shapes = IconButtonDefaults.shapes()
) {
BadgedBox(
badge = {
Badge(modifier = Modifier.size(6.dp))
}
Icon(
Icons.Filled.Delete,
stringResource(R.string.delete)
)
}
IconButton(
onClick = vm::updateSources,
shapes = IconButtonDefaults.shapes()
) {
Icon(
Icons.Filled.Refresh,
stringResource(R.string.refresh)
)
}
}
)
} else {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Image(
painter = logoPainter,
contentDescription = null,
modifier = Modifier.size(32.dp)
)
Text(stringResource(R.string.app_name))
}
},
actions = {
if (availableUpdate != null) {
IconButton(
onClick = onUpdateClick,
shapes = IconButtonDefaults.shapes()
) {
Icon(Icons.Outlined.Update, stringResource(R.string.update))
BadgedBox(
badge = {
Badge(modifier = Modifier.size(6.dp))
}
) {
Icon(Icons.Filled.Update, stringResource(R.string.update))
}
}
}
}
IconButton(onClick = onAnnouncementsClick) {
IconButton(onClick = onAnnouncementsClick, shapes = IconButtonDefaults.shapes()) {
BadgedBox(
badge = {
if (vm.unreadAnnouncement != null) {
@@ -233,128 +394,127 @@ fun DashboardScreen(
}
) {
Icon(
Icons.Outlined.Notifications,
Icons.Filled.Notifications,
stringResource(R.string.announcements)
)
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
}
},
applyContainerColor = true
)
}
},
floatingActionButton = {
HapticFloatingActionButton(
onClick = {
vm.cancelSourceSelection()
when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> {
if (availablePatches < 1) {
androidContext.toast(resources.getString(R.string.no_patch_found))
composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.BUNDLES.ordinal
)
}
return@HapticFloatingActionButton
IconButton(onClick = onSettingsClick, shapes = IconButtonDefaults.shapes()) {
Icon(Icons.Filled.Settings, stringResource(R.string.settings))
}
if (vm.android11BugActive) {
showAndroid11Dialog = true
return@HapticFloatingActionButton
}
onAppSelectorClick()
}
DashboardPage.BUNDLES.ordinal -> {
showAddBundleDialog = true
}
}
}
) { Icon(Icons.Default.Add, stringResource(R.string.add)) }
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
DashboardPage.entries.forEachIndexed { index, page ->
HapticTab(
selected = pagerState.currentPage == index,
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(stringResource(page.titleResId)) },
icon = { Icon(page.icon, null) },
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
},
containerColor = Color.Transparent,
floatingActionButton = {
DashboardFab(
pagerState = pagerState,
onPatchAppClick = {
vm.cancelSourceSelection()
if (availablePatches < 1) {
androidContext.toast(resources.getString(R.string.no_patch_found))
composableScope.launch {
pagerState.animateScrollToPage(DashboardPage.BUNDLES.ordinal)
}
return@DashboardFab
}
if (vm.android11BugActive) {
showAndroid11Dialog = true
return@DashboardFab
}
onAppSelectorClick()
},
onAddBundleClick = {
vm.cancelSourceSelection()
showAddBundleDialog = true
}
)
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
if (!bundlesSelectable) {
PillTabBar(
pagerState = pagerState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
DashboardPage.entries.forEachIndexed { index, page ->
PillTab(
index = index,
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(stringResource(page.titleResId)) },
icon = { Icon(page.icon, null) }
)
}
}
}
Notifications(
if (!Aapt.supportsDevice()) {
{
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.unsupported_architecture_warning),
onDismiss = null
)
}
} else null,
if (vm.showBatteryOptimizationsWarning) {
{
val batteryOptimizationsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
vm.updateBatteryOptimizationsWarning()
}
NotificationCard(
isWarning = true,
icon = Icons.Default.BatteryAlert,
text = stringResource(R.string.battery_optimization_notification),
onClick = {
batteryOptimizationsLauncher.launch(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.fromParts("package", androidContext.packageName, null)
)
)
}
)
}
} else null,
if (showNewDownloaderNotification) {
{
NotificationCard(
text = stringResource(R.string.new_downloader_notification),
icon = Icons.Outlined.Download,
modifier = Modifier.clickable(onClick = onDownloaderClick),
actions = {
TextButton(onClick = vm::ignoreNewDownloaders) {
Text(stringResource(R.string.dismiss))
Notifications(
if (!Aapt.supportsDevice()) {
{
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.unsupported_architecture_warning),
onDismiss = null
)
}
} else null,
if (vm.showBatteryOptimizationsWarning) {
{
val batteryOptimizationsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
vm.updateBatteryOptimizationsWarning()
}
}
)
}
} else null,
NotificationCard(
isWarning = true,
icon = Icons.Default.BatteryAlert,
text = stringResource(R.string.battery_optimization_notification),
onClick = {
batteryOptimizationsLauncher.launch(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.fromParts("package", androidContext.packageName, null)
)
)
}
)
}
} else null,
if (showNewDownloaderNotification) {
{
NotificationCard(
text = stringResource(R.string.new_downloader_notification),
icon = Icons.Outlined.Download,
modifier = Modifier.clickable(onClick = onDownloaderClick),
actions = {
TextButton(onClick = vm::ignoreNewDownloaders, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.dismiss))
}
}
)
}
} else null,
vm.unreadAnnouncement?.let { announcement ->
{
NotificationCard(
text = stringResource(R.string.new_announcement, announcement.title),
icon = Icons.Filled.Notifications,
actions = {
TextButton(onClick = vm::markUnreadAnnouncementRead) {
TextButton(onClick = vm::markUnreadAnnouncementRead, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.dismiss))
}
TextButton(
onClick = {
vm.markUnreadAnnouncementRead()
onAnnouncementClick(announcement)
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.view_announcement))
}
@@ -365,35 +525,91 @@ fun DashboardScreen(
}
)
HorizontalPager(
state = pagerState,
userScrollEnabled = true,
modifier = Modifier.fillMaxSize(),
pageContent = { index ->
when (DashboardPage.entries[index]) {
DashboardPage.DASHBOARD -> {
InstalledAppsScreen(
onAppClick = { onAppClick(it.currentPackageName) }
)
}
DashboardPage.BUNDLES -> {
BackHandler {
if (bundlesSelectable) vm.cancelSourceSelection() else composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.DASHBOARD.ordinal
)
}
HorizontalPager(
state = pagerState,
userScrollEnabled = true,
modifier = Modifier.fillMaxSize(),
pageContent = { index ->
when (DashboardPage.entries[index]) {
DashboardPage.DASHBOARD -> {
InstalledAppsScreen(
onAppClick = { onAppClick(it.currentPackageName) }
)
}
DashboardPage.BUNDLES -> {
BackHandler {
if (bundlesSelectable) vm.cancelSourceSelection() else composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.DASHBOARD.ordinal
)
}
}
BundleListScreen(
eventsFlow = vm.bundleListEventsFlow,
setSelectedSourceCount = { selectedSourceCount = it }
setSelectedSourceCount = { selectedSourceCount = it },
onBundleClick = onBundleClick
)
}
}
}
}
)
)
}
}
}
}
}
@Composable
private fun DashboardFab(
pagerState: PagerState,
onPatchAppClick: () -> Unit,
onAddBundleClick: () -> Unit
) {
val swipeProgress = (pagerState.currentPage + pagerState.currentPageOffsetFraction).coerceIn(0f, 1f)
HapticExtendedFloatingActionButton(
onClick = {
when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> onPatchAppClick()
DashboardPage.BUNDLES.ordinal -> onAddBundleClick()
}
},
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { FabTextCrossfade(swipeProgress) }
)
}
@Composable
private fun FabTextCrossfade(progress: Float) {
val texts = listOf(
stringResource(R.string.fab_patch_app),
stringResource(R.string.fab_add_patches)
)
Layout(
content = {
texts.forEachIndexed { index, text ->
val textProgress = if (index == 0) 1f - progress else progress
val direction = if (index == 0) 1f else -1f
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.graphicsLayer {
alpha = textProgress
translationX = (1f - textProgress) * direction * -50f
}
)
}
}
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val width = lerp(placeables[0].width.toFloat(), placeables[1].width.toFloat(), progress).toInt()
val height = placeables.maxOf { it.height }
layout(width, height) {
placeables.forEach { it.placeRelative(0, 0) }
}
}
}
@@ -416,12 +632,13 @@ fun Notifications(
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun Android11Dialog(onDismissRequest: () -> Unit, onContinue: () -> Unit) {
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onContinue) {
TextButton(onClick = onContinue, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.continue_))
}
},

View File

@@ -1,6 +1,5 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -15,7 +14,9 @@ import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -40,6 +41,7 @@ import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.SegmentedButton
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
@@ -153,11 +155,10 @@ fun InstalledAppInfoScreen(
)
}
Column(
ListSection(
modifier = Modifier.padding(vertical = 16.dp)
) {
SettingsListItem(
modifier = Modifier.clickable(onClick = { showAppliedPatchesDialog = true }),
headlineContent = stringResource(R.string.applied_patches),
supportingContent =
(viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let {
@@ -167,8 +168,9 @@ fun InstalledAppInfoScreen(
it
)
},
trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) }
)
trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) },
onClick = { showAppliedPatchesDialog = true },
)
SettingsListItem(
headlineContent = stringResource(R.string.package_name),
@@ -191,6 +193,7 @@ fun InstalledAppInfoScreen(
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UninstallDialog(
onDismiss: () -> Unit,
@@ -204,14 +207,15 @@ fun UninstallDialog(
onClick = {
onConfirm()
onDismiss()
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.ok))
}
},
dismissButton = {
TextButton(
onClick = onDismiss
onClick = onDismiss, shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.cancel))
}

View File

@@ -2,25 +2,27 @@ package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.EmptyState
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
@@ -39,40 +41,39 @@ fun InstalledAppsScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
) {
val apps = installedApps
if (apps == null) {
item(key = "LOADING") {
LoadingIndicator()
}
} else if (apps.isNotEmpty()) {
items(
apps,
key = { "APP-" + it.currentPackageName },
contentType = { "APP" },
) { installedApp ->
val packageInfo = viewModel.packageInfoMap[installedApp.currentPackageName]
ListItem(
modifier = Modifier.clickable { onAppClick(installedApp) },
leadingContent = {
AppIcon(
packageInfo,
contentDescription = null,
Modifier.size(36.dp)
installedApps?.let { installedApps ->
if (installedApps.isNotEmpty()) {
items(
installedApps,
key = { it.currentPackageName }
) { installedApp ->
viewModel.packageInfoMap[installedApp.currentPackageName].let { packageInfo ->
ListItem(
modifier = Modifier.clickable { onAppClick(installedApp) },
leadingContent = {
AppIcon(
packageInfo,
contentDescription = null,
Modifier.size(36.dp)
)
},
headlineContent = { AppLabel(packageInfo, defaultText = null) },
supportingContent = { Text(installedApp.currentPackageName) }
)
},
headlineContent = { AppLabel(packageInfo, defaultText = null) },
supportingContent = { Text(installedApp.currentPackageName) }
)
}
}
} else {
item {
EmptyState(
icon = Icons.Outlined.Apps,
title = R.string.no_patched_apps_found,
description = R.string.no_patched_apps_description
)
}
}
} else {
item(key = "NONE") {
Text(
text = stringResource(R.string.no_patched_apps_found),
style = MaterialTheme.typography.titleLarge
)
}
}
} ?: item { LoadingIndicator() }
}
}
}

View File

@@ -0,0 +1,450 @@
package app.revanced.manager.ui.screen
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.BottomContentBar
import app.revanced.manager.ui.component.ColumnWithScrollbarEdgeShadow
import app.revanced.manager.ui.screen.onboarding.AppsStepContent
import app.revanced.manager.ui.screen.onboarding.PermissionsStepContent
import app.revanced.manager.ui.screen.onboarding.SourcesStepContent
import app.revanced.manager.ui.screen.onboarding.UpdatesStepContent
import app.revanced.manager.ui.viewmodel.ApiDownloaderState
import app.revanced.manager.ui.viewmodel.OnboardingStep
import app.revanced.manager.ui.viewmodel.OnboardingViewModel
import app.revanced.manager.util.RequestInstallAppsContract
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@SuppressLint("BatteryLife")
@Composable
fun OnboardingScreen(
onFinish: () -> Unit,
onAppClick: (String) -> Unit,
vm: OnboardingViewModel = koinViewModel()
) {
val context = LocalContext.current
val apps by vm.apps.collectAsStateWithLifecycle(initialValue = null)
val suggestedVersions by vm.suggestedVersions.collectAsStateWithLifecycle(initialValue = emptyMap())
val apiDownloaderLabel by vm.apiDownloaderLabel.collectAsStateWithLifecycle()
val apiDownloaderSignature by vm.apiDownloaderSignature.collectAsStateWithLifecycle()
val currentStep = vm.currentStep
val scope = rememberCoroutineScope()
var managerUpdatesEnabled by rememberSaveable { mutableStateOf(true) }
var patchesUpdatesEnabled by rememberSaveable { mutableStateOf(true) }
var showSkipPermissionsDialog by remember { mutableStateOf(false) }
val installAppsLauncher = rememberLauncherForActivityResult(RequestInstallAppsContract) {
vm.refreshPermissionStates()
}
val notificationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) {
vm.refreshPermissionStates()
}
val batteryOptimizationLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
vm.refreshPermissionStates()
}
BackHandler(enabled = currentStep != OnboardingStep.Permissions) {
vm.retreat()
}
val (stepTitle, stepDescription, stepButtons) = when (currentStep) {
OnboardingStep.Permissions -> Triple(
stringResource(R.string.onboarding_permissions_subtitle),
stringResource(R.string.onboarding_permissions_skip_description),
StepButtons(
primaryAction = { vm.advance() },
primaryEnabled = vm.allPermissionsGranted,
secondaryAction = if (!vm.allPermissionsGranted) {
{ showSkipPermissionsDialog = true }
} else null
)
)
OnboardingStep.Updates -> Triple(
stringResource(R.string.onboarding_updates_subtitle),
stringResource(R.string.auto_updates_dialog_note),
StepButtons(
primaryAction = {
scope.launch {
vm.applyAutoUpdatePrefs(
managerEnabled = managerUpdatesEnabled,
patchesEnabled = patchesUpdatesEnabled
)
}
vm.advance()
},
)
)
OnboardingStep.Sources -> Triple(
stringResource(R.string.onboarding_sources_subtitle),
stringResource(R.string.onboarding_sources_description),
StepButtons(
primaryAction = { vm.advance() },
primaryEnabled = vm.apiDownloaderState == ApiDownloaderState.UP_TO_DATE,
secondaryAction = if (vm.apiDownloaderState != ApiDownloaderState.UP_TO_DATE) {
{ vm.advance() }
} else null
)
)
OnboardingStep.Apps -> Triple(
stringResource(R.string.select_app),
stringResource(R.string.onboarding_apps_subtitle),
StepButtons(
primaryTextRes = null,
secondaryAction = {
scope.launch {
vm.completeOnboarding()
onFinish()
}
}
)
)
}
val onboardingButtons: @Composable () -> Unit = {
OnboardingButtons(stepButtons)
}
val stepContent: @Composable ColumnScope.(showDetails: Boolean) -> Unit = { showDetails ->
AnimatedContent(
modifier = Modifier
.padding(vertical = 16.dp)
.weight(1f),
targetState = currentStep,
transitionSpec = {
val direction = if (targetState.ordinal > initialState.ordinal) 1 else -1
(slideInHorizontally { width -> width * direction } + fadeIn())
.togetherWith(slideOutHorizontally { width -> -width * direction } + fadeOut())
},
label = "onboarding_content",
) { step ->
ColumnWithScrollbarEdgeShadow(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (showDetails) StepTitle(stepTitle)
when (step) {
OnboardingStep.Permissions -> PermissionsStepContent(
canInstallUnknownApps = vm.canInstallUnknownApps,
isNotificationsEnabled = vm.isNotificationsEnabled,
isBatteryOptimizationExempt = vm.isBatteryOptimizationExempt,
onRequestInstallApps = { installAppsLauncher.launch(context.packageName) },
onRequestNotifications = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
},
onRequestBatteryOptimization = {
batteryOptimizationLauncher.launch(
Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.fromParts("package", context.packageName, null)
)
)
}
)
OnboardingStep.Updates -> UpdatesStepContent(
managerEnabled = managerUpdatesEnabled,
patchesEnabled = patchesUpdatesEnabled,
onManagerEnabledChange = { managerUpdatesEnabled = it },
onPatchesEnabledChange = { patchesUpdatesEnabled = it }
)
OnboardingStep.Sources -> {
SourcesStepContent(
apiDownloaderState = vm.apiDownloaderState,
apiDownloaderProgress = vm.apiDownloaderProgress,
apiDownloaderIsUpdate = vm.apiDownloaderIsUpdate,
apiDownloaderIsTrusted = vm.apiDownloaderIsTrusted,
apiDownloaderName = apiDownloaderLabel,
apiDownloaderSignature = apiDownloaderSignature,
onInstallApiDownloader = vm::startApiDownloaderInstall,
onRetryApiDownloader = vm::retryApiDownloaderDownload,
onTrustApiDownloader = {
vm.apiDownloaderPackageName.value?.let { vm.trustDownloader(it) }
}
)
}
OnboardingStep.Apps -> AppsStepContent(
modifier = Modifier.weight(1f),
apps = apps,
suggestedVersions = suggestedVersions,
onAppClick = { packageName ->
scope.launch {
vm.completeOnboarding()
onAppClick(packageName)
}
}
)
}
if (showDetails) StepDescription(stepDescription)
}
}
}
Scaffold { paddingValues ->
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
val useSplitLayout = maxWidth >= maxHeight
if (useSplitLayout) {
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
ColumnWithScrollbarEdgeShadow(
modifier = Modifier
.fillMaxSize()
.padding(start = 16.dp, end = 12.dp, top = 24.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
OnboardingHeader()
StepDetails(title = stepTitle, description = stepDescription)
}
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
stepContent(false)
Box(modifier = Modifier.padding(horizontal = 16.dp)) {
onboardingButtons()
}
}
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp, 24.dp),
) {
OnboardingHeader()
stepContent(true)
onboardingButtons()
}
}
}
if (showSkipPermissionsDialog) {
AlertDialog(
onDismissRequest = { showSkipPermissionsDialog = false },
title = { Text(stringResource(R.string.onboarding_permissions_skip_title)) },
text = { Text(stringResource(R.string.onboarding_permissions_skip_description)) },
confirmButton = {
TextButton(
onClick = {
showSkipPermissionsDialog = false
vm.advance()
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.onboarding_permissions_skip_anyway))
}
},
dismissButton = {
TextButton(onClick = { showSkipPermissionsDialog = false }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
}
)
}
}
}
@Composable
private fun StepDetails(title: String, description: String) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth()
)
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun StepTitle(title: String) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun StepDescription(description: String) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun OnboardingHeader() {
val context = LocalContext.current
val resources = LocalResources.current
val icon = rememberDrawablePainter(drawable = remember(resources) {
AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring)
})
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = stringResource(R.string.onboarding_welcome_to),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = icon,
contentDescription = null,
modifier = Modifier.size(40.dp)
)
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun OnboardingButtons(stepButtons: StepButtons) {
BottomContentBar(contentPadding = PaddingValues(0.dp)) {
stepButtons.secondaryAction?.let { action ->
TextButton(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
onClick = action,
shapes = ButtonDefaults.shapes()
) {
Text(text = stringResource(stepButtons.secondaryTextRes!!))
}
}
stepButtons.primaryTextRes?.let { textRes ->
Button(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
onClick = stepButtons.primaryAction,
enabled = stepButtons.primaryEnabled,
shapes = ButtonDefaults.shapes()
) {
Text(text = stringResource(textRes))
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null
)
}
}
}
}
data class StepButtons(
val primaryTextRes: Int? = R.string.next,
val primaryAction: () -> Unit = {},
val primaryEnabled: Boolean = true,
val secondaryTextRes: Int? = R.string.onboarding_skip,
val secondaryAction: (() -> Unit)? = null
)

View File

@@ -23,10 +23,13 @@ import androidx.compose.material.icons.outlined.PostAdd
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -48,6 +51,7 @@ import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.ShareSheet
import app.revanced.manager.ui.component.InstallerStatusDialog
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.patcher.InstallPickerDialog
@@ -58,7 +62,7 @@ import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.toast
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PatcherScreen(
onBackClick: () -> Unit,
@@ -71,8 +75,13 @@ fun PatcherScreen(
val context = LocalContext.current
val resources = LocalResources.current
val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), viewModel::export)
var showLogExportSheet by rememberSaveable { mutableStateOf(false) }
val exportApkLauncher = rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), viewModel::export)
val saveLogsLauncher = rememberLauncherForActivityResult(CreateDocument("text/plain")) { uri ->
viewModel.saveLogs(uri)
showLogExportSheet = false
viewModel.clearPreparedLogExport()
}
val patcherSucceeded by viewModel.patcherSucceeded.observeAsState(null)
val canInstall by remember { derivedStateOf { patcherSucceeded == true && (viewModel.installedPackageName != null || !viewModel.isInstalling) } }
@@ -119,6 +128,21 @@ fun PatcherScreen(
)
}
if (showLogExportSheet) {
ShareSheet(
onDismissRequest = {
showLogExportSheet = false
viewModel.clearPreparedLogExport()
},
title = stringResource(R.string.export_patcher_logs),
preview = viewModel.logPreviewText,
shareUri = viewModel.preparedLogUri,
onSaveToFilesClick = {
saveLogsLauncher.launch(viewModel.logFileName())
}
)
}
viewModel.packageInstallerStatus?.let {
InstallerStatusDialog(it, viewModel, viewModel::dismissPackageInstallerDialog)
}
@@ -136,14 +160,16 @@ fun PatcherScreen(
onDismissRequest = viewModel::rejectInteraction,
confirmButton = {
TextButton(
onClick = viewModel::allowInteraction
onClick = viewModel::allowInteraction,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.continue_))
}
},
dismissButton = {
TextButton(
onClick = viewModel::rejectInteraction
onClick = viewModel::rejectInteraction,
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.cancel))
}
@@ -168,13 +194,18 @@ fun PatcherScreen(
actions = {
IconButton(
onClick = { exportApkLauncher.launch("${viewModel.packageName}_${viewModel.version}_revanced_patched.apk") },
enabled = patcherSucceeded == true
enabled = patcherSucceeded == true,
shapes = IconButtonDefaults.shapes(),
) {
Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk))
}
IconButton(
onClick = { viewModel.exportLogs(context) },
enabled = patcherSucceeded != null
onClick = {
viewModel.prepareLogExport()
showLogExportSheet = true
},
enabled = patcherSucceeded != null,
shapes = IconButtonDefaults.shapes(),
) {
Icon(Icons.Outlined.PostAdd, stringResource(id = R.string.save_logs))
}
@@ -221,7 +252,7 @@ fun PatcherScreen(
expandedCategory = category
}
LinearProgressIndicator(
LinearWavyProgressIndicator(
progress = { viewModel.progress },
modifier = Modifier.fillMaxWidth()
)

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -30,6 +31,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material.icons.outlined.Deselect
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Restore
@@ -38,19 +40,23 @@ import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -77,6 +83,7 @@ import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.CheckedFilterChip
import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.SafeguardDialog
import app.revanced.manager.ui.component.SearchBar
import app.revanced.manager.ui.component.haptics.HapticCheckbox
@@ -96,7 +103,9 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, FlowPreview::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, FlowPreview::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
fun PatchesSelectorScreen(
onSave: (PatchSelection?, Options) -> Unit,
@@ -172,45 +181,45 @@ fun PatchesSelectorScreen(
}
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp)
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.patch_selector_sheet_filter_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp)
)
Text(
text = stringResource(R.string.patch_selector_sheet_filter_compat_title),
style = MaterialTheme.typography.titleMedium
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
CheckedFilterChip(
selected = viewModel.filter and SHOW_INCOMPATIBLE == 0,
onClick = { viewModel.toggleFlag(SHOW_INCOMPATIBLE) },
label = { Text(stringResource(R.string.this_version)) }
Text(
text = stringResource(R.string.patch_selector_sheet_filter_compat_title),
style = MaterialTheme.typography.titleMedium
)
CheckedFilterChip(
selected = viewModel.filter and SHOW_UNIVERSAL != 0,
onClick = { viewModel.toggleFlag(SHOW_UNIVERSAL) },
label = { Text(stringResource(R.string.universal)) },
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CheckedFilterChip(
selected = viewModel.filter and SHOW_INCOMPATIBLE == 0,
onClick = { viewModel.toggleFlag(SHOW_INCOMPATIBLE) },
label = { Text(stringResource(R.string.this_version)) }
)
CheckedFilterChip(
selected = viewModel.filter and SHOW_UNIVERSAL != 0,
onClick = { viewModel.toggleFlag(SHOW_UNIVERSAL) },
label = { Text(stringResource(R.string.universal)) },
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
Text(
text = stringResource(R.string.patch_selector_sheet_actions_title),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 8.dp)
)
fun guardedAction(action: () -> Unit) {
showBottomSheet = false
if (viewModel.selectionWarningEnabled) {
@@ -220,52 +229,65 @@ fun PatchesSelectorScreen(
}
}
ActionItem(
icon = Icons.Outlined.Restore,
text = stringResource(R.string.restore_default_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.restoreDefaults(uid)
}
}
}
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Text(
text = stringResource(R.string.patch_selector_sheet_actions_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 16.dp)
)
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.deselectAll(bundles, uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.SwapHoriz,
text = stringResource(R.string.invert_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.invertSelection(bundles, uid)
}
}
}
)
if (bundles.size > 1 && currentBundle != null) {
ListSection(
modifier = Modifier.padding(horizontal = 16.dp),
contentPadding = PaddingValues(0.dp)
) {
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all_except, currentBundle.name),
icon = Icons.Outlined.Restore,
text = stringResource(R.string.restore_default_selection),
onClick = {
guardedAction {
viewModel.deselectAllExcept(bundles, currentBundle.uid)
executeScopedAction { uid ->
viewModel.restoreDefaults(uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.deselectAll(bundles, uid)
}
}
}
)
ActionItem(
icon = Icons.Outlined.SwapHoriz,
text = stringResource(R.string.invert_selection),
onClick = {
guardedAction {
executeScopedAction { uid ->
viewModel.invertSelection(bundles, uid)
}
}
}
)
if (bundles.size > 1 && currentBundle != null) {
ActionItem(
icon = Icons.Outlined.Deselect,
text = stringResource(R.string.deselect_all_except, currentBundle.name),
onClick = {
guardedAction {
viewModel.deselectAllExcept(bundles, currentBundle.uid)
}
}
)
}
}
}
}
@@ -374,7 +396,8 @@ fun PatchesSelectorScreen(
} else {
onBackClick()
}
}
},
shapes = IconButtonDefaults.shapes()
) {
Icon(
modifier = Modifier.rotate(rotation),
@@ -392,7 +415,8 @@ fun PatchesSelectorScreen(
if (searchExpanded) {
IconButton(
onClick = { setQuery("") },
enabled = query.isNotEmpty()
enabled = query.isNotEmpty(),
shapes = IconButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Filled.Close,
@@ -400,7 +424,7 @@ fun PatchesSelectorScreen(
)
}
} else {
IconButton(onClick = { showBottomSheet = true }) {
IconButton(onClick = { showBottomSheet = true }, shapes = IconButtonDefaults.shapes()) {
Icon(
imageVector = Icons.Outlined.FilterList,
contentDescription = stringResource(R.string.more)
@@ -511,12 +535,12 @@ fun PatchesSelectorScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(top = 16.dp)
.padding(top = 4.dp)
) {
if (bundles.size > 1) {
SecondaryScrollableTabRow(
PrimaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
edgePadding = 0.dp
) {
bundles.forEachIndexed { index, bundle ->
HapticTab(
@@ -627,6 +651,7 @@ private fun UniversalPatchWarningDialog(
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun PatchItem(
patch: PatchInfo,
@@ -650,7 +675,7 @@ private fun PatchItem(
supportingContent = patch.description?.let { { Text(it) } },
trailingContent = {
if (patch.options?.isNotEmpty() == true) {
IconButton(onClick = onOptionsDialog, enabled = compatible) {
IconButton(onClick = onOptionsDialog, enabled = compatible, shapes = IconButtonDefaults.shapes()) {
Icon(Icons.Outlined.Settings, null)
}
}
@@ -658,6 +683,7 @@ private fun PatchItem(
colors = transparentListItemColors
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ListHeader(
title: String,
@@ -673,7 +699,7 @@ fun ListHeader(
},
trailingContent = onHelpClick?.let {
{
IconButton(onClick = it) {
IconButton(onClick = it, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Outlined.HelpOutline,
stringResource(R.string.help)
@@ -685,6 +711,7 @@ fun ListHeader(
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun IncompatiblePatchesDialog(
appVersion: String,
@@ -695,7 +722,7 @@ private fun IncompatiblePatchesDialog(
},
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.ok))
}
},
@@ -710,6 +737,7 @@ private fun IncompatiblePatchesDialog(
}
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun IncompatiblePatchDialog(
appVersion: String,
@@ -721,7 +749,7 @@ private fun IncompatiblePatchDialog(
},
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.ok))
}
},
@@ -737,20 +765,24 @@ private fun IncompatiblePatchDialog(
}
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ActionItem(
icon: ImageVector,
text: String,
onClick: () -> Unit
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
SegmentedListItem(
onClick = onClick,
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 1),
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
leadingContent = { Icon(icon, contentDescription = null) },
headlineContent = { Text(text) },
colors = transparentListItemColors
)
) { Text(text) }
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ScopeDialog(
bundleName: String,
@@ -761,18 +793,18 @@ private fun ScopeDialog(
onDismissRequest = onDismissRequest,
title = { Text(stringResource(R.string.scope_dialog_title)) },
confirmButton = {
TextButton(onClick = onAllPatches) {
TextButton(onClick = onAllPatches, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.scope_all_patches))
}
},
dismissButton = {
TextButton(onClick = onBundleOnly) {
TextButton(onClick = onBundleOnly, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.scope_bundle_patches, bundleName))
}
}
)
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun OptionsDialog(
patch: PatchInfo,
@@ -788,8 +820,8 @@ private fun OptionsDialog(
title = patch.name,
onBackClick = onDismissRequest,
actions = {
IconButton(onClick = reset) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
IconButton(onClick = reset, shapes = IconButtonDefaults.shapes()) {
Icon(Icons.Filled.Restore, stringResource(R.string.reset))
}
}
)

View File

@@ -15,7 +15,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@@ -215,9 +217,12 @@ fun SelectedAppInfoScreen(
)
error?.let {
Text(
stringResource(it.resourceId),
text = stringResource(it.resourceId),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp)
modifier = Modifier
.padding(top = 6.dp)
.padding(horizontal = 24.dp)
)
}
@@ -279,6 +284,7 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun AppSourceSelectorDialog(
downloaders: List<LoadedDownloader>,
@@ -296,7 +302,7 @@ private fun AppSourceSelectorDialog(
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},

View File

@@ -1,24 +1,53 @@
package app.revanced.manager.ui.screen
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SwapVert
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.BuildConfig
import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.BottomContentBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.settings.ExpressiveListIcon
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.model.navigation.Settings
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.koin.compose.koinInject
private data class Section(
@@ -28,14 +57,26 @@ private data class Section(
val destination: Settings.Destination,
)
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsScreen(onBackClick: () -> Unit, navigate: (Settings.Destination) -> Unit) {
val prefs: PreferencesManager = koinInject()
val showDeveloperSettings by prefs.showDeveloperSettings.getAsState()
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
val context = LocalContext.current
val appIcon = rememberDrawablePainter(
drawable = remember(context) {
AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring)
}
)
val settingsSections = remember(showDeveloperSettings) {
listOfNotNull(
val generalSections = remember {
listOf(
Section(
R.string.general,
R.string.general_description,
@@ -53,7 +94,12 @@ fun SettingsScreen(onBackClick: () -> Unit, navigate: (Settings.Destination) ->
R.string.downloads_description,
Icons.Outlined.Download,
Settings.Downloads
),
)
)
}
val advancedSections = remember {
listOf(
Section(
R.string.import_export,
R.string.import_export_description,
@@ -65,42 +111,97 @@ fun SettingsScreen(onBackClick: () -> Unit, navigate: (Settings.Destination) ->
R.string.advanced_description,
Icons.Outlined.Tune,
Settings.Advanced
),
Section(
R.string.about,
R.string.app_name,
Icons.Outlined.Info,
Settings.About
),
Section(
R.string.developer_options,
R.string.developer_options_description,
Icons.Outlined.Code,
Settings.Developer
).takeIf { showDeveloperSettings }
)
)
}
val developerSection = remember(showDeveloperSettings) {
Section(
R.string.developer_options,
R.string.developer_options_description,
Icons.Outlined.Code,
Settings.Developer
).takeIf { showDeveloperSettings }
}
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.settings),
onBackClick = onBackClick,
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
}
},
bottomBar = {
BottomContentBar(modifier = Modifier.navigationBarsPadding()) {
SettingsListItem(
modifier = Modifier.clip(MaterialTheme.shapes.large),
headlineContent = stringResource(
R.string.about_app_name,
stringResource(R.string.app_name)
),
supportingContent = BuildConfig.VERSION_NAME,
leadingContent = {
Image(
painter = appIcon,
contentDescription = stringResource(R.string.app_name),
modifier = Modifier.size(42.dp)
)
},
onClick = { navigate(Settings.About) }
)
}
},
modifier = Modifier.then(
scrollBehavior.let { Modifier.nestedScroll(it.nestedScrollConnection) }
)
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.fillMaxSize(),
state = scrollState
) {
settingsSections.forEach { (name, description, icon, destination) ->
SettingsListItem(
modifier = Modifier.clickable { navigate(destination) },
headlineContent = stringResource(name),
supportingContent = stringResource(description),
leadingContent = { Icon(icon, null) }
)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ListSection {
generalSections.forEach { (name, description, icon, destination) ->
SettingsListItem(
headlineContent = stringResource(name),
supportingContent = stringResource(description),
leadingContent = { ExpressiveListIcon(icon = icon) },
onClick = { navigate(destination) }
)
}
}
ListSection {
advancedSections.forEach { (name, description, icon, destination) ->
SettingsListItem(
headlineContent = stringResource(name),
supportingContent = stringResource(description),
leadingContent = { ExpressiveListIcon(icon = icon) },
onClick = { navigate(destination) }
)
}
}
developerSection?.let { (name, description, icon, destination) ->
ListSection {
SettingsListItem(
headlineContent = stringResource(name),
supportingContent = stringResource(description),
leadingContent = { ExpressiveListIcon(icon = icon) },
onClick = { navigate(destination) }
)
}
}
}
}
}

View File

@@ -1,23 +1,27 @@
package app.revanced.manager.ui.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cancel
import androidx.compose.material.icons.outlined.InstallMobile
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -34,18 +38,15 @@ import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.BottomContentBar
import app.revanced.manager.ui.component.ColumnWithScrollbarEdgeShadow
import app.revanced.manager.ui.component.settings.Changelog
import app.revanced.manager.ui.viewmodel.UpdateViewModel
import app.revanced.manager.ui.viewmodel.UpdateViewModel.State
import app.revanced.manager.util.relativeTime
import com.gigamole.composefadingedges.content.FadingEdgesContentType
import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig
import com.gigamole.composefadingedges.fill.FadingEdgesFillType
import com.gigamole.composefadingedges.verticalFadingEdges
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Stable
fun UpdateScreen(
@@ -54,6 +55,21 @@ fun UpdateScreen(
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val buttonConfig = when (vm.state) {
State.CAN_DOWNLOAD -> Triple(
{ vm.downloadUpdate() },
R.string.download,
Icons.Outlined.InstallMobile
)
State.DOWNLOADING -> Triple(onBackClick, R.string.cancel, Icons.Outlined.Cancel)
State.CAN_INSTALL -> Triple(
{ vm.installUpdate() },
R.string.install_app,
Icons.Outlined.InstallMobile
)
else -> null
}
Scaffold(
topBar = {
AppTopBar(
@@ -76,41 +92,30 @@ fun UpdateScreen(
onBackClick = onBackClick
)
},
floatingActionButton = {
val buttonConfig = when (vm.state) {
State.CAN_DOWNLOAD -> Triple(
{ vm.downloadUpdate() },
R.string.download,
Icons.Outlined.InstallMobile
)
State.DOWNLOADING -> Triple(onBackClick, R.string.cancel, Icons.Outlined.Cancel)
State.CAN_INSTALL -> Triple(
{ vm.installUpdate() },
R.string.install_app,
Icons.Outlined.InstallMobile
)
else -> null
}
bottomBar = {
buttonConfig?.let { (onClick, textRes, icon) ->
HapticExtendedFloatingActionButton(
onClick = onClick::invoke,
icon = { Icon(icon, null) },
text = { Text(stringResource(textRes)) }
)
BottomContentBar(modifier = Modifier.navigationBarsPadding()) {
FilledTonalButton(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
onClick = onClick::invoke,
shapes = ButtonDefaults.shapes()
) {
Icon(icon, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(textRes))
}
}
}
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues),
modifier = Modifier.padding(paddingValues),
) {
if (vm.state == State.DOWNLOADING)
LinearProgressIndicator(
LinearWavyProgressIndicator(
progress = { vm.downloadProgress },
modifier = Modifier.fillMaxWidth(),
)
@@ -121,21 +126,15 @@ fun UpdateScreen(
onDownloadAnyways = { vm.downloadUpdate(true) }
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
vm.releaseInfo?.let { changelog ->
Changelog(changelog)
}
vm.releaseInfo?.let { changelog ->
Changelog(changelog)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun MeteredDownloadConfirmationDialog(
onDismiss: () -> Unit,
@@ -144,7 +143,7 @@ private fun MeteredDownloadConfirmationDialog(
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
TextButton(onDismiss) {
TextButton(onDismiss, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -153,7 +152,8 @@ private fun MeteredDownloadConfirmationDialog(
onClick = {
onDismiss()
onDownloadAnyways()
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.download))
}
@@ -166,27 +166,11 @@ private fun MeteredDownloadConfirmationDialog(
@Composable
private fun ColumnScope.Changelog(releaseInfo: ReVancedAsset) {
val scrollState = rememberScrollState()
Column(
ColumnWithScrollbarEdgeShadow(
modifier = Modifier
.weight(1f)
.verticalScroll(scrollState)
.verticalFadingEdges(
fillType = FadingEdgesFillType.FadeColor(
color = MaterialTheme.colorScheme.background,
fillStops = Triple(0F, 0.55F, 1F),
secondStopAlpha = 1F
),
contentType = FadingEdgesContentType.Dynamic.Scroll(
state = scrollState,
scrollConfig = FadingEdgesScrollConfig.Dynamic(
animationSpec = spring(),
isLerpByDifferenceForPartialContent = true,
scrollFactor = 1.25F
)
),
length = 350.dp
)
.fillMaxSize()
.padding(16.dp),
) {
Changelog(
markdown = releaseInfo.description.replace("`", ""),

View File

@@ -0,0 +1,32 @@
package app.revanced.manager.ui.screen.onboarding
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.onboarding.OnboardingAppList
import app.revanced.manager.util.AppInfo
@Composable
fun AppsStepContent(
modifier: Modifier = Modifier,
apps: List<AppInfo>?,
suggestedVersions: Map<String, String?>,
onAppClick: (String) -> Unit
) {
apps?.let {
OnboardingAppList(
modifier = modifier,
apps = apps,
suggestedVersions = suggestedVersions,
onAppClick = onAppClick
)
} ?: Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
}

View File

@@ -0,0 +1,146 @@
package app.revanced.manager.ui.screen.onboarding
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.outlined.BatteryAlert
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.settings.SettingsListItem
@Composable
fun PermissionsStepContent(
canInstallUnknownApps: Boolean,
isNotificationsEnabled: Boolean,
isBatteryOptimizationExempt: Boolean,
onRequestInstallApps: () -> Unit,
onRequestNotifications: () -> Unit,
onRequestBatteryOptimization: () -> Unit
) {
ListSection(contentPadding = PaddingValues(0.dp)) {
PermissionItem(
icon = Icons.Outlined.Security,
title = stringResource(R.string.permission_install_apps),
description = stringResource(R.string.permission_install_apps_description),
isGranted = canInstallUnknownApps,
onRequest = onRequestInstallApps
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
PermissionItem(
icon = Icons.Outlined.Notifications,
title = stringResource(R.string.permission_notifications),
description = stringResource(R.string.permission_notifications_description),
isGranted = isNotificationsEnabled,
onRequest = onRequestNotifications
)
}
PermissionItem(
icon = Icons.Outlined.BatteryAlert,
title = stringResource(R.string.permission_battery),
description = stringResource(R.string.permission_battery_description),
isGranted = isBatteryOptimizationExempt,
onRequest = onRequestBatteryOptimization
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun PermissionItem(
icon: ImageVector,
title: String,
description: String,
isGranted: Boolean,
onRequest: () -> Unit
) {
SettingsListItem(
onClick = if (isGranted) null else onRequest,
headlineContent = title,
supportingContent = description,
leadingContent = {
OnboardingLeadingIcon(
icon = icon,
containerColor = if (isGranted) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
iconColor = if (isGranted) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
},
trailingContent = {
if (isGranted) {
OnboardingLeadingIcon(
icon = Icons.Default.Check,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconColor = MaterialTheme.colorScheme.onPrimaryContainer,
size = 32.dp,
iconSize = 16.dp
)
} else {
FilledTonalButton(
onClick = onRequest,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
shapes = ButtonDefaults.shapes()
) {
Text(
text = stringResource(R.string.permission_grant),
style = MaterialTheme.typography.labelMedium
)
}
}
}
)
}
@Composable
internal fun OnboardingLeadingIcon(
icon: ImageVector,
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
iconColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
size: Dp = 40.dp,
iconSize: Dp = 22.dp
) {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(containerColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(iconSize),
tint = iconColor
)
}
}

View File

@@ -0,0 +1,177 @@
package app.revanced.manager.ui.screen.onboarding
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.GppMaybe
import androidx.compose.material.icons.outlined.Verified
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.TrustDialog
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.ApiDownloaderState
@Composable
fun SourcesStepContent(
apiDownloaderState: ApiDownloaderState,
apiDownloaderProgress: Float,
apiDownloaderIsUpdate: Boolean,
apiDownloaderIsTrusted: Boolean,
apiDownloaderName: String?,
apiDownloaderSignature: String?,
onInstallApiDownloader: () -> Unit,
onRetryApiDownloader: () -> Unit,
onTrustApiDownloader: () -> Unit,
) {
ListSection(contentPadding = PaddingValues(0.dp)) {
when (apiDownloaderState) {
ApiDownloaderState.CHECKING -> {
SettingsListItem(
headlineContent = stringResource(R.string.api_downloader),
supportingContent = stringResource(R.string.api_downloader_checking),
leadingContent = {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
)
}
ApiDownloaderState.AVAILABLE -> {
SettingsListItem(
headlineContent = stringResource(R.string.api_downloader),
supportingContent = stringResource(
if (apiDownloaderIsUpdate) R.string.api_downloader_update_available
else R.string.api_downloader_available
),
onClick = onInstallApiDownloader,
leadingContent = {
OnboardingLeadingIcon(
icon = Icons.Outlined.Download,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
)
}
ApiDownloaderState.DOWNLOADING -> {
SettingsListItem(
headlineContent = stringResource(R.string.api_downloader),
supportingContent = stringResource(
if (apiDownloaderIsUpdate) R.string.api_downloader_updating
else R.string.api_downloader_downloading
),
leadingContent = {
Box(contentAlignment = Alignment.Center) {
OnboardingLeadingIcon(
icon = Icons.Outlined.Download,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconColor = MaterialTheme.colorScheme.onPrimaryContainer
)
CircularProgressIndicator(
progress = { apiDownloaderProgress },
modifier = Modifier.size(36.dp),
strokeWidth = 2.dp
)
}
}
)
}
ApiDownloaderState.INSTALLING -> {
SettingsListItem(
headlineContent = stringResource(R.string.api_downloader),
supportingContent = stringResource(R.string.api_downloader_installing),
leadingContent = {
Box(contentAlignment = Alignment.Center) {
OnboardingLeadingIcon(
icon = Icons.Outlined.Download,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconColor = MaterialTheme.colorScheme.onPrimaryContainer
)
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
strokeWidth = 2.dp
)
}
}
)
}
ApiDownloaderState.UP_TO_DATE -> {
var showTrustDialog by rememberSaveable { mutableStateOf(false) }
if (showTrustDialog && !apiDownloaderIsTrusted && apiDownloaderName != null && apiDownloaderSignature != null) {
TrustDialog(
title = R.string.downloader_trust_dialog_title,
body = stringResource(R.string.downloader_trust_dialog_body),
downloaderName = apiDownloaderName,
signature = apiDownloaderSignature,
onDismiss = { showTrustDialog = false },
onConfirm = { onTrustApiDownloader() }
)
}
if (apiDownloaderIsTrusted) {
SettingsListItem(
headlineContent = stringResource(R.string.api_downloader),
supportingContent = stringResource(R.string.api_downloader_up_to_date),
leadingContent = {
OnboardingLeadingIcon(
icon = Icons.Outlined.Verified,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
)
} else {
SettingsListItem(
headlineContent = stringResource(R.string.api_downloader),
supportingContent = stringResource(R.string.downloader_state_untrusted_tap_to_trust),
onClick = { showTrustDialog = true },
leadingContent = {
OnboardingLeadingIcon(
icon = Icons.Outlined.GppMaybe,
containerColor = MaterialTheme.colorScheme.errorContainer,
iconColor = MaterialTheme.colorScheme.onErrorContainer
)
}
)
}
}
ApiDownloaderState.FAILED -> {
SettingsListItem(
headlineContent = stringResource(R.string.api_downloader),
supportingContent = stringResource(R.string.api_downloader_failed),
leadingContent = {
OnboardingLeadingIcon(
icon = Icons.Outlined.ErrorOutline,
containerColor = MaterialTheme.colorScheme.errorContainer,
iconColor = MaterialTheme.colorScheme.onErrorContainer
)
},
onClick = onRetryApiDownloader
)
}
else -> {}
}
}
}

View File

@@ -0,0 +1,63 @@
package app.revanced.manager.ui.screen.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.ColumnWithScrollbarEdgeShadow
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.SettingsListItem
@Composable
fun UpdatesStepContent(
managerEnabled: Boolean,
patchesEnabled: Boolean,
onManagerEnabledChange: (Boolean) -> Unit,
onPatchesEnabledChange: (Boolean) -> Unit,
) {
ListSection(contentPadding = PaddingValues(0.dp)) {
UpdatesItem(
headline = stringResource(R.string.auto_updates_dialog_manager),
icon = Icons.Outlined.Update,
checked = managerEnabled,
onCheckedChange = onManagerEnabledChange
)
UpdatesItem(
headline = stringResource(R.string.auto_updates_dialog_patches),
icon = Icons.Outlined.Source,
checked = patchesEnabled,
onCheckedChange = onPatchesEnabledChange
)
}
}
@Composable
private fun UpdatesItem(
headline: String,
icon: ImageVector,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
SettingsListItem(
onClick = { onCheckedChange(!checked) },
headlineContent = headline,
leadingContent = { OnboardingLeadingIcon(icon = icon) },
trailingContent = {
HapticCheckbox(
checked = checked,
onCheckedChange = null
)
}
)
}

View File

@@ -17,10 +17,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.MailOutline
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
@@ -51,6 +54,7 @@ import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedSocial
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.viewmodel.AboutViewModel
@@ -61,7 +65,9 @@ import app.revanced.manager.util.toast
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@Composable
fun AboutSettingsScreen(
onBackClick: () -> Unit,
@@ -233,6 +239,7 @@ fun AboutSettingsScreen(
FilledTonalButton(
onClick = onClick,
modifier = Modifier.weight(1f),
shapes = ButtonDefaults.shapes()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -258,6 +265,7 @@ fun AboutSettingsScreen(
IconButton(
onClick = onClick,
modifier = Modifier.padding(end = 8.dp),
shapes = IconButtonDefaults.shapes()
) {
Icon(
icon,
@@ -287,14 +295,13 @@ fun AboutSettingsScreen(
)
}
}
Column {
ListSection {
listItems.forEach { (title, description, onClick) ->
SettingsListItem(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
modifier = Modifier.fillMaxWidth(),
headlineContent = title,
supportingContent = description
supportingContent = description,
onClick = onClick
)
}
}

View File

@@ -4,33 +4,44 @@ import android.app.ActivityManager
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build
import android.view.HapticFeedbackConstants
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Api
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.WorkOutline
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -46,9 +57,8 @@ import androidx.core.content.getSystemService
import androidx.lifecycle.viewModelScope
import app.revanced.manager.BuildConfig
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.IntegerItem
import app.revanced.manager.ui.component.settings.SafeguardBooleanItem
@@ -58,7 +68,7 @@ import app.revanced.manager.util.toast
import app.revanced.manager.util.withHapticFeedback
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AdvancedSettingsScreen(
onBackClick: () -> Unit,
@@ -74,47 +84,81 @@ fun AdvancedSettingsScreen(
activityManager.largeMemoryClass
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
val showDeveloperSettings by viewModel.prefs.showDeveloperSettings.getAsState()
var developerTaps by rememberSaveable { mutableIntStateOf(0) }
LaunchedEffect(developerTaps) {
if (developerTaps < 10) return@LaunchedEffect
if (showDeveloperSettings) {
context.toast(context.getString(R.string.developer_options_already_enabled))
} else {
viewModel.prefs.showDeveloperSettings.update(true)
context.toast(context.getString(R.string.developer_options_enabled))
}
developerTaps = 0
}
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.advanced),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.advanced)) },
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = Modifier.then(
scrollBehavior.let { Modifier.nestedScroll(it.nestedScrollConnection) }
),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(paddingValues),
state = scrollState
) {
GroupHeader(stringResource(R.string.manager))
ListSection(
title = stringResource(R.string.manager),
leadingContent = { Icon(Icons.Outlined.WorkOutline, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
val apiUrl by viewModel.prefs.api.getAsState()
var showApiUrlDialog by rememberSaveable { mutableStateOf(false) }
val apiUrl by viewModel.prefs.api.getAsState()
var showApiUrlDialog by rememberSaveable { mutableStateOf(false) }
if (showApiUrlDialog) {
APIUrlDialog(
currentUrl = apiUrl,
defaultUrl = viewModel.prefs.api.default,
onSubmit = {
showApiUrlDialog = false
it?.let(viewModel::setApiUrl)
}
if (showApiUrlDialog) {
APIUrlDialog(
currentUrl = apiUrl,
defaultUrl = viewModel.prefs.api.default,
onSubmit = {
showApiUrlDialog = false
it?.let(viewModel::setApiUrl)
}
)
}
SettingsListItem(
headlineContent = stringResource(R.string.api_url),
supportingContent = stringResource(R.string.api_url_description),
onClick = { showApiUrlDialog = true }
)
}
SettingsListItem(
headlineContent = stringResource(R.string.api_url),
supportingContent = stringResource(R.string.api_url_description),
modifier = Modifier.clickable {
showApiUrlDialog = true
}
)
GroupHeader(stringResource(R.string.safeguards))
ListSection(
title = stringResource(R.string.safeguards),
leadingContent = { Icon(Icons.Outlined.Security, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
SafeguardBooleanItem(
preference = viewModel.prefs.disablePatchVersionCompatCheck,
coroutineScope = viewModel.viewModelScope,
@@ -136,39 +180,50 @@ fun AdvancedSettingsScreen(
description = R.string.patch_selection_safeguard_description,
confirmationText = R.string.patch_selection_safeguard_confirmation
)
SafeguardBooleanItem(
preference = viewModel.prefs.disableUniversalPatchCheck,
coroutineScope = viewModel.viewModelScope,
headline = R.string.universal_patches_safeguard,
description = R.string.universal_patches_safeguard_description,
confirmationText = R.string.universal_patches_safeguard_confirmation
)
SafeguardBooleanItem(
preference = viewModel.prefs.disableUniversalPatchCheck,
coroutineScope = viewModel.viewModelScope,
headline = R.string.universal_patches_safeguard,
description = R.string.universal_patches_safeguard_description,
confirmationText = R.string.universal_patches_safeguard_confirmation
)
}
GroupHeader(stringResource(R.string.patcher))
BooleanItem(
preference = viewModel.prefs.useProcessRuntime,
coroutineScope = viewModel.viewModelScope,
headline = R.string.process_runtime,
description = R.string.process_runtime_description,
)
IntegerItem(
preference = viewModel.prefs.patcherProcessMemoryLimit,
coroutineScope = viewModel.viewModelScope,
headline = R.string.process_runtime_memory_limit,
description = R.string.process_runtime_memory_limit_description,
)
ListSection(
title = stringResource(R.string.patcher),
leadingContent = { Icon(Icons.Outlined.Tune, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
val useProcessRuntime by viewModel.prefs.useProcessRuntime.getAsState()
GroupHeader(stringResource(R.string.debugging))
val exportDebugLogsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) {
it?.let(viewModel::exportDebugLogs)
BooleanItem(
preference = viewModel.prefs.useProcessRuntime,
coroutineScope = viewModel.viewModelScope,
headline = R.string.process_runtime,
description = R.string.process_runtime_description,
)
AnimatedVisibility(
visible = useProcessRuntime,
) {
IntegerItem(
preference = viewModel.prefs.patcherProcessMemoryLimit,
coroutineScope = viewModel.viewModelScope,
headline = R.string.process_runtime_memory_limit,
description = R.string.process_runtime_memory_limit_description,
unit = "MiB",
)
}
SettingsListItem(
headlineContent = stringResource(R.string.debug_logs_export),
modifier = Modifier.clickable { exportDebugLogsLauncher.launch(viewModel.debugLogFileName) }
)
val clipboard = remember { context.getSystemService<ClipboardManager>()!! }
val deviceContent = """
}
ListSection(
title = stringResource(R.string.debugging),
leadingContent = { Icon(Icons.Outlined.BugReport, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
val exportDebugLogsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) {
it?.let(viewModel::exportDebugLogs)
}
val clipboard = remember { context.getSystemService<ClipboardManager>()!! }
val deviceContent = """
Version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})
Build type: ${BuildConfig.BUILD_TYPE}
Model: ${Build.MODEL}
@@ -176,25 +231,28 @@ fun AdvancedSettingsScreen(
Supported Archs: ${Build.SUPPORTED_ABIS.joinToString(", ")}
Memory limit: $memoryLimit
""".trimIndent()
SettingsListItem(
modifier = Modifier.combinedClickable(
onClick = { },
SettingsListItem(
headlineContent = stringResource(R.string.debug_logs_export),
onClick = { exportDebugLogsLauncher.launch(viewModel.debugLogFileName) }
)
SettingsListItem(
headlineContent = stringResource(R.string.about_device),
supportingContent = deviceContent,
onClick = { developerTaps++ },
onLongClickLabel = stringResource(R.string.copy_to_clipboard),
onLongClick = {
clipboard.setPrimaryClip(
ClipData.newPlainText("Device Information", deviceContent)
)
context.toast(resources.getString(R.string.toast_copied_to_clipboard))
}.withHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
),
headlineContent = stringResource(R.string.about_device),
supportingContent = deviceContent
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) context.toast(resources.getString(R.string.toast_copied_to_clipboard))
}.withHapticFeedback(HapticFeedbackConstantsCompat.LONG_PRESS)
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun APIUrlDialog(currentUrl: String, defaultUrl: String, onSubmit: (String?) -> Unit) {
var url by rememberSaveable(currentUrl) { mutableStateOf(currentUrl) }
@@ -205,13 +263,14 @@ private fun APIUrlDialog(currentUrl: String, defaultUrl: String, onSubmit: (Stri
TextButton(
onClick = {
onSubmit(url)
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.api_url_dialog_save))
}
},
dismissButton = {
TextButton(onClick = { onSubmit(null) }) {
TextButton(onClick = { onSubmit(null) }, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -227,6 +286,7 @@ private fun APIUrlDialog(currentUrl: String, defaultUrl: String, onSubmit: (Stri
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
@@ -245,7 +305,7 @@ private fun APIUrlDialog(currentUrl: String, defaultUrl: String, onSubmit: (Stri
onValueChange = { url = it },
label = { Text(stringResource(R.string.api_url)) },
trailingIcon = {
IconButton(onClick = { url = defaultUrl }) {
IconButton(onClick = { url = defaultUrl }, shapes = IconButtonDefaults.shapes()) {
Icon(Icons.Outlined.Restore, stringResource(R.string.api_url_dialog_reset))
}
}

View File

@@ -1,62 +1,102 @@
package app.revanced.manager.ui.screen.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.PostAdd
import androidx.compose.material.icons.outlined.WorkOutline
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.DeveloperOptionsViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DeveloperSettingsScreen(
onBackClick: () -> Unit,
vm: DeveloperOptionsViewModel = koinViewModel()
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
val prefs: PreferencesManager = koinInject()
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.developer_options),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.developer_options)) },
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
modifier = Modifier.then(
scrollBehavior.let { Modifier.nestedScroll(it.nestedScrollConnection) }
),
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
GroupHeader(stringResource(R.string.manager))
BooleanItem(
preference = prefs.showDeveloperSettings,
headline = R.string.developer_options,
description = R.string.developer_options_description,
)
ColumnWithScrollbar(
modifier = Modifier.padding(paddingValues),
state = scrollState
) {
ListSection(
title = stringResource(R.string.manager),
leadingContent = { Icon(Icons.Outlined.WorkOutline, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
BooleanItem(
preference = prefs.showDeveloperSettings,
headline = R.string.developer_options,
description = R.string.developer_options_description,
)
SettingsListItem(
headlineContent = stringResource(R.string.reset_onboarding),
supportingContent = stringResource(R.string.reset_onboarding_description),
onClick = vm::resetOnboarding
)
}
GroupHeader(stringResource(R.string.patches))
SettingsListItem(
headlineContent = stringResource(R.string.patches_force_download),
modifier = Modifier.clickable(onClick = vm::redownloadBundles)
)
SettingsListItem(
headlineContent = stringResource(R.string.patches_reset),
modifier = Modifier.clickable(onClick = vm::redownloadBundles)
)
ListSection(
title = stringResource(R.string.patches),
leadingContent = { Icon(Icons.Outlined.PostAdd, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
SettingsListItem(
headlineContent = stringResource(R.string.patches_force_download),
onClick = vm::redownloadBundles
)
SettingsListItem(
headlineContent = stringResource(R.string.patches_reset),
onClick = vm::redownloadBundles
)
}
}
}
}

View File

@@ -0,0 +1,268 @@
package app.revanced.manager.ui.screen.settings
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.SignalWifiOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPackageState
import app.revanced.manager.ui.component.BottomContentBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.EmptyState
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.TrustDialog
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
import org.koin.androidx.compose.koinViewModel
import java.security.MessageDigest
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalStdlibApi::class)
@Composable
fun DownloaderInfoScreen(
packageName: String,
onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel()
) {
val downloaderStates by viewModel.downloaderStates.collectAsStateWithLifecycle()
val apiDownloaderPackageName by viewModel.apiDownloaderPackageName.collectAsStateWithLifecycle(null)
val state = downloaderStates[packageName]
val packageInfo = remember(packageName, state) { viewModel.pm.getPackageInfo(packageName) }
if (packageInfo == null) {
LaunchedEffect(packageName) {
onBackClick()
}
return
}
val context = LocalContext.current
val signature =
remember(packageName) {
val androidSignature = viewModel.pm.getSignature(packageName)
val hash = MessageDigest.getInstance("SHA-256")
.digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase)
}
val appName = remember {
packageInfo.applicationInfo?.loadLabel(context.packageManager)
?.toString()
?: packageName
}
val displayNames = remember(state) {
(state as? DownloaderPackageState.Loaded)?.downloaders?.map { it.name }.orEmpty()
}
val isEnabled = state is DownloaderPackageState.Loaded || state is DownloaderPackageState.Failed
val canUpdate = apiDownloaderPackageName == packageName
val versionName = packageInfo.versionName.orEmpty()
val isDeleting = viewModel.deletingDownloaderPackageName == packageName
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
var showTrustDialog by rememberSaveable { mutableStateOf(false) }
val scrollState = androidx.compose.foundation.rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
if (showDeleteConfirmationDialog) {
ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = {
viewModel.deleteDownloader(packageName)
},
title = stringResource(R.string.delete),
description = stringResource(R.string.downloader_delete_single_description, appName),
icon = Icons.Outlined.Delete
)
}
if (showTrustDialog) {
TrustDialog(
title = R.string.downloader_trust_dialog_title,
body = stringResource(R.string.downloader_trust_dialog_body),
downloaderName = appName,
signature = signature ?: stringResource(R.string.field_not_set),
onDismiss = { showTrustDialog = false },
onConfirm = {
viewModel.trustDownloader(packageName)
showTrustDialog = false
}
)
}
Scaffold(
topBar = {
MediumFlexibleTopAppBar(
title = { Text(appName) },
subtitle = versionName.takeIf { it.isNotEmpty() }?.let {
{ Text("v$it") }
},
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
IconButton(
onClick = { showDeleteConfirmationDialog = true },
enabled = !isDeleting,
shapes = IconButtonDefaults.shapes()
) {
Icon(Icons.Filled.Delete, stringResource(R.string.delete))
}
if (canUpdate) {
IconButton(
onClick = { viewModel.updateDownloader(packageName) },
enabled = !isDeleting,
shapes = IconButtonDefaults.shapes()
) {
Icon(Icons.Filled.Update, stringResource(R.string.update))
}
}
},
scrollBehavior = scrollBehavior
)
},
bottomBar = {
BottomContentBar(modifier = Modifier.navigationBarsPadding()) {
if (isDeleting) {
FilledTonalButton(
onClick = {},
enabled = false,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shapes = ButtonDefaults.shapes()
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.api_downloader_deleting))
}
} else if (isEnabled) {
FilledTonalButton(
onClick = { viewModel.revokeDownloaderTrust(packageName) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.disable))
}
} else {
Button(
onClick = { showTrustDialog = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
),
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.enable))
}
}
}
},
modifier = Modifier.then(
scrollBehavior?.let { Modifier.nestedScroll(it.nestedScrollConnection) } ?: Modifier
)
) { paddingValues ->
if (displayNames.isNotEmpty()) {
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
state = scrollState,
) {
ListSection(
title = stringResource(R.string.downloaders),
leadingContent = {
Icon(
Icons.Outlined.Download,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
) {
displayNames.forEachIndexed { index, downloaderName ->
SegmentedListItem(
onClick = {},
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = displayNames.size
)
) {
Text(downloaderName)
}
}
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
EmptyState(
icon = Icons.Outlined.SignalWifiOff,
title = R.string.downloader_sources_unavailable_title,
description = R.string.downloader_sources_unavailable_description
)
}
}
}
}

View File

@@ -1,68 +1,127 @@
package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPackageState
import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.BottomContentBar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.EmptyState
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.PillTab
import app.revanced.manager.ui.component.PillTabBar
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import java.security.MessageDigest
@OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class)
private enum class DownloadsTab(
val titleResId: Int,
val icon: ImageVector
) {
Downloaders(R.string.downloaders, Icons.Outlined.Download),
Apps(R.string.tab_apps, Icons.Outlined.Apps)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalStdlibApi::class)
@Composable
fun DownloadsSettingsScreen(
onBackClick: () -> Unit,
onDownloaderClick: (String) -> Unit,
viewModel: DownloadsViewModel = koinViewModel()
) {
val context = LocalContext.current
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val downloaderStates by viewModel.downloaderStates.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val pagerState = rememberPagerState(pageCount = { DownloadsTab.entries.size })
val scope = rememberCoroutineScope()
val downloaderListState = rememberLazyListState()
val appsListState = rememberLazyListState()
val selectedListState = rememberSelectedListState(
selectedPage = pagerState.currentPage,
downloaderListState = downloaderListState,
appsListState = appsListState
)
val canScroll by remember(selectedListState) {
derivedStateOf {
selectedListState.canScrollBackward || selectedListState.canScrollForward
}
}
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = { canScroll }
)
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
var showCancelInstallConfirmationDialog by rememberSaveable { mutableStateOf(false) }
val currentTab = DownloadsTab.entries[pagerState.currentPage]
val isInstallingDownloader = viewModel.downloaderInstallState != DownloadsViewModel.DownloaderInstallState.IDLE
val handleBack = {
if (isInstallingDownloader) {
showCancelInstallConfirmationDialog = true
} else {
onBackClick()
}
}
// since we still want to have predictive back gesture at times when dialog isnt there
PredictiveBackHandler(enabled = isInstallingDownloader) { progress ->
try {
progress.collect()
showCancelInstallConfirmationDialog = true
} catch (_: CancellationException) {}
}
if (showDeleteConfirmationDialog) {
ConfirmDialog(
@@ -74,15 +133,36 @@ fun DownloadsSettingsScreen(
)
}
if (showCancelInstallConfirmationDialog) {
ConfirmDialog(
onDismiss = { showCancelInstallConfirmationDialog = false },
onConfirm = {
viewModel.cancelDefaultDownloaderInstall()
showCancelInstallConfirmationDialog = false
onBackClick()
},
title = stringResource(R.string.cancel_downloader_install_title),
description = stringResource(R.string.cancel_downloader_install_description),
icon = Icons.Outlined.Download
)
}
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.downloads),
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.downloads)) },
navigationIcon = {
IconButton(onClick = handleBack, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior,
onBackClick = onBackClick,
actions = {
if (viewModel.appSelection.isNotEmpty()) {
IconButton(onClick = { viewModel.deleteApps() }) {
if (currentTab == DownloadsTab.Apps && viewModel.appSelection.isNotEmpty()) {
IconButton(onClick = { showDeleteConfirmationDialog = true }, shapes = IconButtonDefaults.shapes()) {
Icon(Icons.Default.Delete, stringResource(R.string.delete))
}
}
@@ -91,147 +171,49 @@ fun DownloadsSettingsScreen(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
PullToRefreshBox(
onRefresh = viewModel::refreshDownloaders,
isRefreshing = viewModel.isRefreshingDownloaders,
modifier = Modifier.padding(paddingValues)
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize()
PillTabBar(
pagerState = pagerState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
item {
GroupHeader(stringResource(R.string.downloaders))
}
downloaderStates.forEach { (packageName, state) ->
item(key = packageName) {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
fun dismiss() {
showDialog = false
}
val packageInfo =
remember(packageName) {
viewModel.pm.getPackageInfo(
packageName
)
} ?: return@item
if (showDialog) {
val signature =
remember(packageName) {
val androidSignature =
viewModel.pm.getSignature(packageName)
val hash = MessageDigest.getInstance("SHA-256")
.digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase)
}
val appName = remember {
packageInfo.applicationInfo?.loadLabel(context.packageManager)
?.toString()
?: packageName
}
when (state) {
is DownloaderPackageState.Loaded -> TrustDialog(
title = R.string.downloader_revoke_trust_dialog_title,
body = stringResource(
R.string.downloader_trust_dialog_body,
packageName,
signature
),
downloaderName = appName,
signature = signature,
onDismiss = ::dismiss,
onConfirm = {
viewModel.revokeDownloaderTrust(packageName)
dismiss()
}
)
is DownloaderPackageState.Failed -> ExceptionViewerDialog(
text = remember(state.throwable) {
state.throwable.stackTraceToString()
},
onDismiss = ::dismiss
)
is DownloaderPackageState.Untrusted -> TrustDialog(
title = R.string.downloader_trust_dialog_title,
body = stringResource(
R.string.downloader_trust_dialog_body
),
downloaderName = appName,
signature = signature,
onDismiss = ::dismiss,
onConfirm = {
viewModel.trustDownloader(packageName)
dismiss()
}
)
}
}
SettingsListItem(
modifier = Modifier.clickable { showDialog = true },
headlineContent = {
AppLabel(
packageInfo = packageInfo,
style = MaterialTheme.typography.titleLarge
)
},
supportingContent = when (state) {
is DownloaderPackageState.Loaded -> StringBuilder(stringResource(R.string.downloader_state_trusted)).apply {
val names = state.downloaders.joinToString("\n") { it.name }
if (names.isNotEmpty()) append("\n\n$names")
}.toString()
is DownloaderPackageState.Failed -> stringResource(R.string.downloader_state_failed)
is DownloaderPackageState.Untrusted -> stringResource(R.string.downloader_state_untrusted)
},
trailingContent = { Text(packageInfo.versionName!!) }
)
}
}
if (downloaderStates.isEmpty()) {
item {
Text(
stringResource(R.string.no_downloader_installed),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
item {
GroupHeader(stringResource(R.string.downloaded_apps))
}
items(downloadedApps, key = { it.packageName to it.version }) { app ->
val selected = app in viewModel.appSelection
SettingsListItem(
modifier = Modifier.clickable { viewModel.toggleApp(app) },
headlineContent = app.packageName,
leadingContent = (@Composable {
HapticCheckbox(
checked = selected,
onCheckedChange = { viewModel.toggleApp(app) }
)
}).takeIf { viewModel.appSelection.isNotEmpty() },
supportingContent = app.version,
tonalElevation = if (selected) 8.dp else 0.dp
DownloadsTab.entries.forEachIndexed { index, tab ->
PillTab(
index = index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(stringResource(tab.titleResId)) },
icon = { Icon(tab.icon, null) }
)
}
if (downloadedApps.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_settings_no_apps),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
}
PullToRefreshBox(
onRefresh = viewModel::refreshDownloaders,
isRefreshing = viewModel.isRefreshingDownloaders,
modifier = Modifier.fillMaxSize()
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
when (DownloadsTab.entries[page]) {
DownloadsTab.Downloaders -> DownloadersTabContent(
downloaderStates = downloaderStates,
listState = downloaderListState,
viewModel = viewModel,
onDownloaderClick = onDownloaderClick,
onInstallDownloaderClick = viewModel::installDefaultDownloader
)
DownloadsTab.Apps -> AppsTabContent(
downloadedApps = downloadedApps,
listState = appsListState,
viewModel = viewModel
)
}
}
@@ -241,56 +223,163 @@ fun DownloadsSettingsScreen(
}
@Composable
private fun TrustDialog(
@StringRes title: Int,
body: String,
downloaderName: String,
signature: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit
private fun rememberSelectedListState(
selectedPage: Int,
downloaderListState: LazyListState,
appsListState: LazyListState
) = remember(selectedPage, downloaderListState, appsListState) {
if (selectedPage == DownloadsTab.Downloaders.ordinal) downloaderListState else appsListState
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun DownloadersTabContent(
downloaderStates: Map<String, DownloaderPackageState>,
listState: LazyListState,
viewModel: DownloadsViewModel,
onDownloaderClick: (String) -> Unit,
onInstallDownloaderClick: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.continue_))
val installState = viewModel.downloaderInstallState
Column(modifier = Modifier.fillMaxSize()) {
if (downloaderStates.isEmpty()) {
Box(modifier = Modifier.weight(1f)) {
EmptyState(
icon = Icons.Outlined.Source,
title = R.string.no_downloaders_installed,
description = R.string.install_revanced_downloaders
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(body)
Card {
Column(
Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(
R.string.downloader_trust_dialog_name,
downloaderName
),
)
OutlinedCard(
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Text(
stringResource(
R.string.downloader_trust_dialog_signature,
signature.chunked(2).joinToString(" ")
), modifier = Modifier.padding(12.dp)
} else {
LazyColumnWithScrollbar(
modifier = Modifier.weight(1f),
state = listState
) {
downloaderStates.entries
.sortedBy { it.key }
.forEach { (packageName, state) ->
item(key = packageName) {
DownloaderItem(
packageName = packageName,
state = state,
viewModel = viewModel,
onClick = onDownloaderClick
)
}
}
}
}
}
if (downloaderStates.isEmpty()) {
BottomContentBar {
FilledTonalButton(
onClick = onInstallDownloaderClick,
enabled = installState == DownloadsViewModel.DownloaderInstallState.IDLE,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shapes = ButtonDefaults.shapes()
) {
if (installState != DownloadsViewModel.DownloaderInstallState.IDLE) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = stringResource(R.string.install_revanced_downloader)
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(
when (installState) {
DownloadsViewModel.DownloaderInstallState.DOWNLOADING -> R.string.api_downloader_downloading
DownloadsViewModel.DownloaderInstallState.INSTALLING -> R.string.api_downloader_installing
DownloadsViewModel.DownloaderInstallState.IDLE -> R.string.install_revanced_downloader
}
)
)
}
}
}
}
}
@Composable
private fun AppsTabContent(
downloadedApps: List<app.revanced.manager.data.room.apps.downloaded.DownloadedApp>,
listState: LazyListState,
viewModel: DownloadsViewModel
) {
if (downloadedApps.isEmpty()) {
EmptyState(
icon = Icons.Outlined.Download,
title = R.string.downloader_settings_no_apps,
description = R.string.downloader_settings_no_apps_description
)
} else {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
state = listState
) {
downloadedApps.forEach { app ->
item(key = "${app.packageName}:${app.version}") {
val selected = app in viewModel.appSelection
ListItem(
modifier = Modifier.clickable { viewModel.toggleApp(app) },
headlineContent = { Text(app.packageName) },
supportingContent = { Text(app.version) },
leadingContent = (@Composable {
HapticCheckbox(
checked = selected,
onCheckedChange = { viewModel.toggleApp(app) }
)
}).takeIf { viewModel.appSelection.isNotEmpty() }
)
}
}
}
}
}
@Composable
private fun DownloaderItem(
packageName: String,
state: DownloaderPackageState,
viewModel: DownloadsViewModel,
onClick: (String) -> Unit
) {
val packageInfo = remember(packageName) { viewModel.pm.getPackageInfo(packageName) } ?: return
ListItem(
modifier = Modifier.clickable { onClick(packageName) },
headlineContent = {
AppLabel(
packageInfo = packageInfo,
style = MaterialTheme.typography.bodyLarge
)
},
supportingContent = {
Text(
stringResource(
when (state) {
is DownloaderPackageState.Loaded -> R.string.downloader_state_enabled
is DownloaderPackageState.Failed -> R.string.downloader_state_failed
is DownloaderPackageState.Untrusted -> R.string.downloader_state_disabled
}
)
)
},
leadingContent = {
AppIcon(
packageInfo = packageInfo,
contentDescription = null,
modifier = Modifier.size(36.dp)
)
},
trailingContent = { Text(packageInfo.versionName.orEmpty()) }
)
}

View File

@@ -2,23 +2,40 @@ package app.revanced.manager.ui.screen.settings
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -28,22 +45,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.SearchView as SearchViewComponent
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.component.settings.ThemeSelector
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.GeneralSettingsViewModel
import app.revanced.manager.util.transparentListItemColors
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun GeneralSettingsScreen(
onBackClick: () -> Unit,
@@ -51,203 +71,316 @@ fun GeneralSettingsScreen(
) {
val prefs = viewModel.prefs
val coroutineScope = viewModel.viewModelScope
var showThemePicker by rememberSaveable { mutableStateOf(false) }
var showLanguagePicker by rememberSaveable { mutableStateOf(false) }
if (showThemePicker) {
ThemePicker(
onDismiss = { showThemePicker = false },
onConfirm = { viewModel.setTheme(it) }
)
}
val scrollState = androidx.compose.foundation.rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
if (showLanguagePicker) {
LanguagePicker(
supportedLocales = viewModel.getSupportedLocales(),
currentLocale = viewModel.getCurrentLocale(),
onDismiss = { showLanguagePicker = false },
onConfirm = { viewModel.setLocale(it) },
getDisplayName = { viewModel.getLocaleDisplayName(it) }
onSelect = { viewModel.setLocale(it) }
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
val animatedSurfaceColor = animateColorAsState(
targetValue = MaterialTheme.colorScheme.surface,
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
label = "surface"
).value
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.general),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.general)) },
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = animatedSurfaceColor,
scrolledContainerColor = animatedSurfaceColor
),
scrollBehavior = scrollBehavior
)
},
containerColor = animatedSurfaceColor,
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(paddingValues),
state = scrollState
) {
GroupHeader(stringResource(R.string.appearance))
val currentLocale = viewModel.getCurrentLocale()
val currentLanguageDisplay = remember(currentLocale) {
currentLocale?.let { viewModel.getLocaleDisplayName(it) }
}
SettingsListItem(
modifier = Modifier.clickable { showLanguagePicker = true },
headlineContent = stringResource(R.string.language),
supportingContent = stringResource(R.string.language_description),
trailingContent = {
FilledTonalButton(onClick = { showLanguagePicker = true }) {
Text(
currentLanguageDisplay
?: stringResource(R.string.language_system_default)
)
}
ListSection(
title = stringResource(R.string.appearance),
leadingContent = { Icon(Icons.Outlined.Palette, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
val currentLocale = viewModel.getCurrentLocale()
val currentLanguageDisplay = remember(currentLocale) {
currentLocale?.let { viewModel.getLocaleDisplayName(it) }
}
)
val theme by prefs.theme.getAsState()
ThemeSelector(
currentTheme = theme,
onThemeSelected = { viewModel.setTheme(it) }
)
val theme by prefs.theme.getAsState()
SettingsListItem(
modifier = Modifier.clickable { showThemePicker = true },
headlineContent = stringResource(R.string.theme),
supportingContent = stringResource(R.string.theme_description),
trailingContent = {
FilledTonalButton(
onClick = {
showThemePicker = true
SettingsListItem(
headlineContent = stringResource(R.string.language),
supportingContent = stringResource(R.string.language_description),
onClick = { showLanguagePicker = true },
trailingContent = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = currentLanguageDisplay
?: stringResource(R.string.language_system_default),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
) {
Text(stringResource(theme.displayName))
}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BooleanItem(
preference = prefs.dynamicColor,
coroutineScope = coroutineScope,
headline = R.string.dynamic_color,
description = R.string.dynamic_color_description
)
}
AnimatedVisibility(theme != Theme.LIGHT) {
BooleanItem(
preference = prefs.pureBlackTheme,
coroutineScope = coroutineScope,
headline = R.string.pure_black_theme,
description = R.string.pure_black_theme_description
)
}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BooleanItem(
preference = prefs.dynamicColor,
coroutineScope = coroutineScope,
headline = R.string.dynamic_color,
description = R.string.dynamic_color_description
)
}
AnimatedVisibility(theme != Theme.LIGHT) {
BooleanItem(
preference = prefs.pureBlackTheme,
coroutineScope = coroutineScope,
headline = R.string.pure_black_theme,
description = R.string.pure_black_theme_description
)
}
GroupHeader(stringResource(R.string.networking))
BooleanItem(
preference = prefs.allowMeteredNetworks,
coroutineScope = coroutineScope,
headline = R.string.allow_metered_networks,
description = R.string.allow_metered_networks_description
)
ListSection(
title = stringResource(R.string.networking),
leadingContent = { Icon(Icons.Outlined.Public, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
BooleanItem(
preference = prefs.allowMeteredNetworks,
coroutineScope = coroutineScope,
headline = R.string.allow_metered_networks,
description = R.string.allow_metered_networks_description
)
}
}
}
}
@Composable
private fun ThemePicker(
onDismiss: () -> Unit,
onConfirm: (Theme) -> Unit,
prefs: PreferencesManager = koinInject()
) {
var selectedTheme by rememberSaveable { mutableStateOf(prefs.theme.getBlocking()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.theme)) },
text = {
Column {
Theme.entries.forEach {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedTheme = it },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedTheme == it,
onClick = { selectedTheme = it })
Text(stringResource(it.displayName))
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(selectedTheme)
onDismiss()
}
) {
Text(stringResource(R.string.apply))
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun LanguagePicker(
supportedLocales: List<Locale>,
currentLocale: Locale?,
onDismiss: () -> Unit,
onConfirm: (Locale?) -> Unit,
getDisplayName: (Locale) -> String
onSelect: (Locale?) -> Unit
) {
var selectedLocale by remember { mutableStateOf(currentLocale) }
val systemDefaultString = stringResource(R.string.language_system_default)
var searchQuery by rememberSaveable { mutableStateOf("") }
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val languageListState = rememberLazyListState()
val isLanguageListScrollable by remember {
derivedStateOf {
languageListState.canScrollBackward || languageListState.canScrollForward
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.language)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
val filteredLocales = remember(searchQuery, supportedLocales, currentLocale) {
if (searchQuery.isEmpty()) {
supportedLocales
} else {
supportedLocales.filter { locale ->
val currentAppLocale = currentLocale ?: Locale.getDefault()
val localizedName = locale.getDisplayName(currentAppLocale)
val nativeName = locale.getDisplayName(locale)
localizedName.contains(searchQuery, ignoreCase = true) ||
nativeName.contains(searchQuery, ignoreCase = true)
}
}
}
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = { isLanguageListScrollable }
)
FullscreenDialog(onDismissRequest = onDismiss) {
if (isSearchActive) {
SearchViewComponent(
query = searchQuery,
onQueryChange = { searchQuery = it },
onActiveChange = {
isSearchActive = it
if (!it) searchQuery = ""
},
placeholder = { Text(stringResource(R.string.search_languages)) },
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = null },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedLocale == null,
onClick = { selectedLocale = null }
)
Text(systemDefaultString)
}
supportedLocales.forEach { locale ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = locale },
verticalAlignment = Alignment.CenterVertically
if (searchQuery.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
HapticRadioButton(
selected = selectedLocale == locale,
onClick = { selectedLocale = locale }
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = stringResource(R.string.search_languages),
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(getDisplayName(locale))
Text(
text = stringResource(R.string.type_anything),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(filteredLocales) { locale ->
val currentAppLocale = currentLocale ?: Locale.getDefault()
val localizedName = locale.getDisplayName(currentAppLocale)
val nativeName = locale.getDisplayName(locale)
ListItem(
modifier = Modifier.clickable {
onSelect(locale)
onDismiss()
},
leadingContent = {
HapticRadioButton(
selected = currentLocale == locale,
onClick = {
onSelect(locale)
onDismiss()
}
)
},
headlineContent = { Text(localizedName) },
supportingContent = if (nativeName != localizedName) {
{ Text(nativeName) }
} else null,
colors = transparentListItemColors
)
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(selectedLocale)
onDismiss()
} else {
Scaffold(
topBar = {
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.language)) },
navigationIcon = {
IconButton(onClick = onDismiss, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
actions = {
IconButton(onClick = { isSearchActive = true }, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.Filled.Search,
contentDescription = stringResource(R.string.search)
)
}
},
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
state = languageListState,
contentPadding = PaddingValues(vertical = 8.dp)
) {
item {
ListItem(
modifier = Modifier.clickable {
onSelect(null)
onDismiss()
},
leadingContent = {
HapticRadioButton(
selected = currentLocale == null,
onClick = {
onSelect(null)
onDismiss()
}
)
},
headlineContent = { Text(systemDefaultString) }
)
}
item {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
items(supportedLocales) { locale ->
val currentAppLocale = currentLocale ?: Locale.getDefault()
val localizedName = locale.getDisplayName(currentAppLocale)
val nativeName = locale.getDisplayName(locale)
ListItem(
modifier = Modifier.clickable {
onSelect(locale)
onDismiss()
},
leadingContent = {
HapticRadioButton(
selected = currentLocale == locale,
onClick = {
onSelect(locale)
onDismiss()
}
)
},
headlineContent = { Text(localizedName) },
supportingContent = if (nativeName != localizedName) {
{ Text(nativeName) }
} else null
)
}
}
) {
Text(stringResource(R.string.apply))
}
}
)
}
}
}

View File

@@ -1,64 +1,96 @@
package app.revanced.manager.ui.screen.settings
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.os.Build
import android.os.PersistableBundle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.PasswordField
import app.revanced.manager.ui.component.bundle.BundleSelector
import app.revanced.manager.ui.component.settings.ExpandableSettingListItem
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.ImportExportViewModel
import app.revanced.manager.ui.viewmodel.PatchStorageStats
import app.revanced.manager.ui.viewmodel.ResetDialogState
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportExportSettingsScreen(
onBackClick: () -> Unit,
@@ -66,8 +98,18 @@ fun ImportExportSettingsScreen(
) {
val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope()
var selectorDialog by rememberSaveable { mutableStateOf<(@Composable () -> Unit)?>(null) }
val prefs = vm.prefs
val contentScrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
contentScrollState.canScrollBackward || contentScrollState.canScrollForward
}
)
val clipboard = remember { context.getSystemService<ClipboardManager>()!! }
var showResetSheet by rememberSaveable { mutableStateOf(false) }
var showKeystorePassword by rememberSaveable { mutableStateOf(false) }
val keystoreAlias by prefs.keystoreAlias.getAsState()
val keystorePass by prefs.keystorePass.getAsState()
val importKeystoreLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) {
@@ -79,8 +121,9 @@ fun ImportExportSettingsScreen(
}
val patchBundles by vm.patchBundles.collectAsStateWithLifecycle(initialValue = emptyList())
val packagesWithSelections by vm.packagesWithSelection.collectAsStateWithLifecycle(initialValue = emptySet())
val packagesWithOptions by vm.packagesWithOptions.collectAsStateWithLifecycle(initialValue = emptySet())
val patchStorageStats by vm.patchStorageStats.collectAsStateWithLifecycle(
initialValue = PatchStorageStats()
)
vm.selectionAction?.let { action ->
val launcher = rememberLauncherForActivityResult(action.activityContract) { uri ->
@@ -92,7 +135,10 @@ fun ImportExportSettingsScreen(
}
if (vm.selectedBundle == null) {
BundleSelector(patchBundles) {
BundleSelector(
sources = patchBundles,
title = action.bundleSelectorTitle
) {
if (it == null) {
vm.clearSelectionAction()
} else {
@@ -117,28 +163,31 @@ fun ImportExportSettingsScreen(
)
}
vm.resetDialogState?.let {
with(vm.resetDialogState!!) {
ConfirmDialog(
onDismiss = { vm.resetDialogState = null },
onConfirm = onConfirm,
title = stringResource(titleResId),
description = dialogOptionName?.let {
stringResource(descriptionResId, it)
} ?: stringResource(descriptionResId),
icon = Icons.Outlined.WarningAmber
)
}
vm.resetDialogState?.let { dialogState ->
ConfirmDialog(
onDismiss = { vm.resetDialogState = null },
onConfirm = dialogState.onConfirm,
title = stringResource(dialogState.titleResId),
description = dialogState.dialogOptionName?.let {
stringResource(dialogState.descriptionResId, it)
} ?: stringResource(dialogState.descriptionResId),
icon = Icons.Outlined.WarningAmber
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.import_export),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.import_export)) },
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -146,239 +195,401 @@ fun ImportExportSettingsScreen(
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(paddingValues),
state = contentScrollState
) {
selectorDialog?.invoke()
GroupHeader(stringResource(R.string.import_))
GroupItem(
onClick = {
importKeystoreLauncher.launch("*/*")
},
headline = R.string.import_keystore,
description = R.string.import_keystore_description
)
GroupItem(
onClick = vm::importSelection,
headline = R.string.import_patch_selection,
description = R.string.import_patch_selection_description
)
GroupHeader(stringResource(R.string.export))
GroupItem(
onClick = {
if (!vm.canExport()) {
context.toast(resources.getString(R.string.export_keystore_unavailable))
return@GroupItem
}
exportKeystoreLauncher.launch("Manager.keystore")
},
headline = R.string.export_keystore,
description = R.string.export_keystore_description
)
GroupItem(
onClick = vm::exportSelection,
headline = R.string.export_patch_selection,
description = R.string.export_patch_selection_description
)
GroupHeader(stringResource(R.string.reset))
GroupItem(
onClick = {
vm.resetDialogState = ResetDialogState.Keystore {
vm.regenerateKeystore()
}
},
headline = R.string.regenerate_keystore,
description = R.string.regenerate_keystore_description
)
ExpandableSettingListItem(
headlineContent = stringResource(R.string.reset_patch_selection),
supportingContent = stringResource(R.string.reset_patch_selection_description),
expandableContent = {
GroupItem(
onClick = {
vm.resetDialogState = ResetDialogState.PatchSelectionAll {
vm.resetSelection()
}
},
headline = R.string.patch_selection_reset_all,
description = R.string.patch_selection_reset_all_description
)
GroupItem(
onClick = {
selectorDialog = {
PackageSelector(packages = packagesWithSelections) { packageName ->
packageName?.also {
vm.resetDialogState =
ResetDialogState.PatchSelectionPackage(packageName) {
vm.resetSelectionForPackage(packageName)
}
}
selectorDialog = null
}
}
},
headline = R.string.patch_selection_reset_package,
description = R.string.patch_selection_reset_package_description
)
if (patchBundles.isNotEmpty()) {
GroupItem(
onClick = {
selectorDialog = {
BundleSelector(sources = patchBundles) { src ->
src?.also {
coroutineScope.launch {
vm.resetDialogState =
ResetDialogState.PatchSelectionBundle(it.name) {
vm.resetSelectionForPatchBundle(it)
}
}
}
selectorDialog = null
}
}
},
headline = R.string.patch_selection_reset_patches,
description = R.string.patch_selection_reset_patches_description
)
}
}
)
ExpandableSettingListItem(
headlineContent = stringResource(R.string.reset_patch_options),
supportingContent = stringResource(R.string.reset_patch_options_description),
expandableContent = {
GroupItem(
onClick = {
vm.resetDialogState = ResetDialogState.PatchOptionsAll {
vm.resetOptions()
}
}, // TODO: patch options import/export.
headline = R.string.patch_options_reset_all,
description = R.string.patch_options_reset_all_description,
)
GroupItem(
onClick = {
selectorDialog = {
PackageSelector(packages = packagesWithOptions) { packageName ->
packageName?.also {
vm.resetDialogState =
ResetDialogState.PatchOptionPackage(packageName) {
vm.resetOptionsForPackage(packageName)
}
}
selectorDialog = null
}
}
},
headline = R.string.patch_options_reset_package,
description = R.string.patch_options_reset_package_description
)
if (patchBundles.isNotEmpty()) {
GroupItem(
onClick = {
selectorDialog = {
BundleSelector(sources = patchBundles) { src ->
src?.also {
coroutineScope.launch {
vm.resetDialogState =
ResetDialogState.PatchOptionBundle(src.name) {
vm.resetOptionsForBundle(src)
}
}
}
selectorDialog = null
}
}
},
headline = R.string.patch_options_reset_patches,
description = R.string.patch_options_reset_patches_description,
)
}
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PackageSelector(packages: Set<String>, onFinish: (String?) -> Unit) {
val context = LocalContext.current
val noPackages = packages.isEmpty()
LaunchedEffect(noPackages) {
if (noPackages) {
context.toast("No packages available.")
onFinish(null)
}
}
if (noPackages) return
ModalBottomSheet(
onDismissRequest = { onFinish(null) }
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
ListSection(
title = stringResource(R.string.keystore),
leadingContent = { Icon(Icons.Outlined.Key, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
Text(
text = "Select package",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
Surface(
modifier = Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
color = animateColorAsState(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.motionScheme.defaultEffectsSpec(),
"surfaceContainerLow"
).value,
) {
Column(modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
ImpoxportDetailColumn(
title = stringResource(R.string.import_keystore_dialog_alias_field),
value = keystoreAlias,
leadingContent = {
IconButton(
onClick = {
clipboard.setPrimaryClip(
ClipData.newPlainText(
resources.getString(R.string.import_keystore_dialog_alias_field),
keystoreAlias
)
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) context.toast(resources.getString(R.string.toast_copied_to_clipboard))
},
shapes = IconButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(R.string.copy_to_clipboard)
)
}
}
)
ImpoxportDetailColumn(
title = stringResource(R.string.import_keystore_dialog_password_field),
value = if (showKeystorePassword) keystorePass else "".repeat(keystorePass.length),
leadingContent = {
val hidePassword = showKeystorePassword
IconButton(onClick = { showKeystorePassword = !showKeystorePassword }, shapes = IconButtonDefaults.shapes()) {
Icon(
imageVector = if (hidePassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (hidePassword) {
stringResource(R.string.hide_password_field)
} else {
stringResource(R.string.show_password_field)
}
)
}
IconButton(
onClick = {
clipboard.setPrimaryClip(
ClipData.newPlainText(
resources.getString(R.string.import_keystore_dialog_password_field),
keystorePass
).apply { description.extras = PersistableBundle().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
} else {
putBoolean("android.content.extra.IS_SENSITIVE", true)
}
} }
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) context.toast(resources.getString(R.string.toast_copied_to_clipboard))
},
shapes = IconButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(R.string.copy_to_clipboard)
)
}
}
)
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
color = animateColorAsState(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.motionScheme.defaultEffectsSpec(),
"surfaceContainerLow"
).value,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilledTonalButton(
onClick = { importKeystoreLauncher.launch("*/*") },
modifier = Modifier.weight(1f),
shapes = ButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Outlined.FileDownload,
contentDescription = null
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.import_))
}
FilledTonalButton(
onClick = {
if (!vm.canExport()) {
context.toast(resources.getString(R.string.export_keystore_unavailable))
return@FilledTonalButton
}
exportKeystoreLauncher.launch("Manager.keystore")
},
modifier = Modifier.weight(1f),
shapes = ButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = null
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.export))
}
}
}
}
ListSection(
title = stringResource(R.string.patches_selections),
leadingContent = { Icon(Icons.Outlined.Source, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
color = animateColorAsState(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.motionScheme.defaultEffectsSpec(),
"surfaceContainerLow"
).value,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 16.dp, bottom = 16.dp, end = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
ImpoxportDetailColumn(
title = stringResource(R.string.patch_selection_packages),
value = patchStorageStats.selectionPackageCount.toString()
)
ImpoxportDetailColumn(
title = stringResource(R.string.patch_selection_entries),
value = patchStorageStats.selectedPatchCount.toString()
)
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
color = animateColorAsState(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.motionScheme.defaultEffectsSpec(),
"surfaceContainerLow"
).value,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
FilledTonalButton(
onClick = vm::importSelection,
modifier = Modifier.weight(1f),
shapes = ButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Outlined.FileDownload,
contentDescription = null
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.import_))
}
FilledTonalButton(
onClick = vm::exportSelection,
modifier = Modifier.weight(1f),
shapes = ButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = null
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.export))
}
}
}
}
ListSection(
title = stringResource(R.string.reset),
leadingContent = { Icon(Icons.Outlined.Restore, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
SettingsListItem(
onClick = {
vm.resetDialogState = ResetDialogState.Keystore {
vm.regenerateKeystore()
}
},
headlineContent = stringResource(R.string.regenerate_keystore),
supportingContent = stringResource(R.string.regenerate_keystore_description)
)
Surface(
modifier = Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(4.dp),
color = animateColorAsState(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialTheme.motionScheme.defaultEffectsSpec(),
"surfaceContainerLow"
).value,
) {
FilledTonalButton(
onClick = { showResetSheet = true },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shapes = ButtonDefaults.shapes()
) {
Icon(
imageVector = Icons.Outlined.Restore,
contentDescription = null
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.reset))
}
}
}
if (showResetSheet) {
ResetBottomSheet(
onDismiss = { showResetSheet = false },
onReset = { resetSelections, resetOptions ->
if (resetSelections) vm.resetSelection()
if (resetOptions) vm.resetOptions()
}
)
}
packages.forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.clickable {
onFinish(it)
}
) {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
@Composable
private fun GroupItem(
onClick: () -> Unit,
@StringRes headline: Int,
@StringRes description: Int? = null
private fun ImpoxportDetailColumn(
modifier: Modifier = Modifier,
title: String,
value: String,
leadingContent: (@Composable RowScope.() -> Unit)? = null
) {
SettingsListItem(
modifier = Modifier.clickable { onClick() },
headlineContent = stringResource(headline),
supportingContent = description?.let { stringResource(it) }
)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
modifier = Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = 1500,
initialDelayMillis = 2500,
spacing = MarqueeSpacing.fractionOfContainer(1f / 5f),
velocity = 55.dp,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
leadingContent?.let { content ->
Row(
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ResetBottomSheet(
onDismiss: () -> Unit,
onReset: (resetSelections: Boolean, resetOptions: Boolean) -> Unit
) {
var resetSelections by rememberSaveable { mutableStateOf(true) }
var resetOptions by rememberSaveable { mutableStateOf(true) }
val sheetState = rememberModalBottomSheetState()
val scope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.reset_configuration),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = 16.dp)
)
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(MaterialTheme.shapes.large),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
SegmentedListItem(
onClick = { resetSelections = !resetSelections },
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 2),
leadingContent = {
HapticCheckbox(
checked = resetSelections,
onCheckedChange = null
)
},
) {
Text(stringResource(R.string.reset_patch_selection))
}
SegmentedListItem(
onClick = { resetOptions = !resetOptions },
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainer
),
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 2),
leadingContent = {
HapticCheckbox(
checked = resetOptions,
onCheckedChange = null
)
},
) {
Text(stringResource(R.string.reset_patch_options))
}
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
scope.launch {
sheetState.hide()
onDismiss()
}
onReset(resetSelections, resetOptions)
},
enabled = resetSelections || resetOptions,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
),
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.reset))
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun KeystoreCredentialsDialog(
onDismissRequest: () -> Unit,
@@ -393,13 +604,14 @@ fun KeystoreCredentialsDialog(
TextButton(
onClick = {
onSubmit(alias, pass)
}
},
shapes = ButtonDefaults.shapes()
) {
Text(stringResource(R.string.import_keystore_dialog_button))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
TextButton(onClick = onDismissRequest, shapes = ButtonDefaults.shapes()) {
Text(stringResource(R.string.cancel))
}
},
@@ -415,6 +627,7 @@ fun KeystoreCredentialsDialog(
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -36,7 +37,7 @@ fun ChangelogsSettingsScreen(
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.changelog),
title = {},
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
)

View File

@@ -1,31 +1,67 @@
package app.revanced.manager.ui.screen.settings.update
import androidx.compose.foundation.clickable
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.WorkOutline
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumFlexibleTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.BottomContentBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.ListSection
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.component.settings.SafeguardBooleanItem
import app.revanced.manager.ui.viewmodel.UpdatesSettingsViewModel
import app.revanced.manager.util.toast
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UpdatesSettingsScreen(
onBackClick: () -> Unit,
@@ -36,70 +72,200 @@ fun UpdatesSettingsScreen(
val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
var checkingForUpdate by remember { mutableStateOf(false) }
val availableUpdate by vm.availableManagerUpdate.collectAsStateWithLifecycle()
val scrollState = androidx.compose.foundation.rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
val appIcon = rememberDrawablePainter(
drawable = remember(context) {
AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring)
}
)
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.updates),
scrollBehavior = scrollBehavior,
onBackClick = onBackClick
MediumFlexibleTopAppBar(
title = { Text(stringResource(R.string.updates)) },
navigationIcon = {
IconButton(onClick = onBackClick, shapes = IconButtonDefaults.shapes()) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
bottomBar = {
BottomContentBar(modifier = Modifier.navigationBarsPadding()) {
FilledTonalButton(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = !checkingForUpdate,
onClick = {
coroutineScope.launch {
if (availableUpdate != null) {
onUpdateClick()
return@launch
}
if (!vm.isConnected) {
context.toast(resources.getString(R.string.no_network_toast))
return@launch
}
checkingForUpdate = true
try {
val version = vm.checkForUpdates()
if (!version.isNullOrEmpty()) onUpdateClick()
} finally {
checkingForUpdate = false
}
}
},
shapes = ButtonDefaults.shapes()
) {
if (checkingForUpdate) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Filled.Update,
contentDescription = stringResource(R.string.refresh)
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(
when {
checkingForUpdate -> R.string.update_check
availableUpdate != null -> R.string.view_update
else -> R.string.manual_update_check
}
)
)
}
}
},
modifier = Modifier.then(
scrollBehavior.let { Modifier.nestedScroll(it.nestedScrollConnection) }
),
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(paddingValues),
state = scrollState
) {
GroupHeader(stringResource(R.string.manager))
SettingsListItem(
modifier = Modifier.clickable {
coroutineScope.launch {
if (!vm.isConnected) {
context.toast(resources.getString(R.string.no_network_toast))
return@launch
ListSection(
title = stringResource(R.string.manager),
leadingContent = { Icon(Icons.Outlined.WorkOutline, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = appIcon,
contentDescription = stringResource(R.string.app_name),
modifier = Modifier
.size(42.dp)
.padding(start = 4.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(4.dp))
Button(
onClick = {
if (!vm.isConnected) {
context.toast(resources.getString(R.string.no_network_toast))
} else {
onChangelogClick()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier
.fillMaxWidth(),
shapes = ButtonDefaults.shapes()
) {
Text(text = stringResource(R.string.changelog))
}
if (vm.checkForUpdates()) onUpdateClick()
}
},
headlineContent = stringResource(R.string.manual_update_check),
supportingContent = stringResource(R.string.manual_update_check_description)
)
}
}
SettingsListItem(
modifier = Modifier.clickable {
if (!vm.isConnected) {
context.toast(resources.getString(R.string.no_network_toast))
return@clickable
}
onChangelogClick()
},
headlineContent = stringResource(R.string.changelog),
supportingContent = stringResource(
R.string.changelog_description
Spacer(modifier = Modifier.height(16.dp))
ListSection {
val managerAutoUpdates by vm.managerAutoUpdates.getAsState()
BooleanItem(
preference = vm.managerAutoUpdates,
headline = R.string.update_checking_manager,
description = R.string.update_checking_manager_description
)
)
BooleanItem(
preference = vm.managerAutoUpdates,
headline = R.string.update_checking_manager,
description = R.string.update_checking_manager_description
)
AnimatedVisibility(visible = managerAutoUpdates) {
BooleanItem(
preference = vm.showManagerUpdateDialogOnLaunch,
headline = R.string.show_manager_update_dialog_on_launch,
description = R.string.show_manager_update_dialog_on_launch_description
)
}
BooleanItem(
preference = vm.showManagerUpdateDialogOnLaunch,
headline = R.string.show_manager_update_dialog_on_launch,
description = R.string.show_manager_update_dialog_on_launch_description
)
SafeguardBooleanItem(
preference = vm.useManagerPrereleases,
headline = R.string.manager_prereleases,
description = R.string.manager_prereleases_description,
confirmationText = R.string.prereleases_warning,
onValueChange = { value ->
coroutineScope.launch {
vm.useManagerPrereleases.update(value)
vm.clearAvailableManagerUpdate()
}
}
)
}
BooleanItem(
preference = vm.useManagerPrereleases,
headline = R.string.manager_prereleases,
description = R.string.manager_prereleases_description
)
Spacer(modifier = Modifier.height(16.dp))
ListSection(
title = stringResource(R.string.downloaders),
leadingContent = { Icon(Icons.Outlined.Download, contentDescription = null, modifier = Modifier.size(18.dp)) }
) {
BooleanItem(
preference = vm.downloaderAutoUpdates,
headline = R.string.update_checking_downloader,
description = R.string.update_checking_downloader_description
)
}
}
}
}

View File

@@ -76,6 +76,7 @@ private val LightColorScheme = lightColorScheme(
scrim = rv_theme_light_scrim,
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ReVancedManagerTheme(
darkTheme: Boolean,
@@ -115,10 +116,11 @@ fun ReVancedManagerTheme(
}
}
MaterialTheme(
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
content = content,
motionScheme = MotionScheme.expressive()
)
}

View File

@@ -47,7 +47,7 @@ class AboutViewModel(
return@launch
}
withContext(Dispatchers.IO) {
reVancedAPI.getInfo("https://api.revanced.app").getOrNull()
reVancedAPI.getInfo().getOrNull()
}?.let {
socials = it.socials
contact = it.contact.email

View File

@@ -12,6 +12,17 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Clock
data class AnnouncementSections(
val activeAnnouncements: List<ReVancedAnnouncement>,
val archivedAnnouncements: List<ReVancedAnnouncement>
) {
val isEmpty: Boolean
get() = activeAnnouncements.isEmpty() && archivedAnnouncements.isEmpty()
}
class AnnouncementsViewModel(
private val announcementRepository: AnnouncementRepository,
@@ -23,23 +34,36 @@ class AnnouncementsViewModel(
val tags = allAnnouncements.map { it?.tags }
val selectedTags = preferences.selectedAnnouncementTags
val readAnnouncements = preferences.readAnnouncements
val showArchived = MutableStateFlow(false)
val announcements = combine(
allAnnouncements,
selectedTags.flow,
showArchived
) { source, selectedTags, showArchived ->
selectedTags.flow
) { source, selectedTags ->
if (source == null) return@combine null
// Only filter by tags that actually exist
val availableTags = source.tags
val validSelected = selectedTags.intersect(availableTags)
source.filter { announcement ->
if (!showArchived && announcement.isArchived) return@filter false
if (validSelected.isEmpty()) {
source
} else {
source.filter { announcement ->
announcement.tags.any(validSelected::contains)
}
}
}
if (!validSelected.isEmpty()) announcement.tags.any(validSelected::contains)
else true
val announcementSections = announcements.map { announcementList ->
announcementList?.let { announcements ->
val now = Clock.System.now()
val (activeAnnouncements, archivedAnnouncements) = announcements.partition { announcement ->
announcement.archivedAt ?: return@partition true
announcement.archivedAt > now
}
AnnouncementSections(
activeAnnouncements = activeAnnouncements,
archivedAnnouncements = archivedAnnouncements
)
}
}

Some files were not shown because too many files have changed in this diff Show More