mirror of
https://github.com/ReVanced/revanced-manager
synced 2026-04-25 17:15:36 +02:00
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:
committed by
oSumAtrIX
parent
5eea987b82
commit
2d42197012
@@ -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
0
api/consumer-rules.pro
Normal 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 {
|
||||
|
||||
1
app/proguard-rules.pro
vendored
1
app/proguard-rules.pro
vendored
@@ -1,4 +1,5 @@
|
||||
-dontobfuscate
|
||||
-dontoptimize
|
||||
-keepattributes *
|
||||
|
||||
-keep class app.revanced.manager.patcher.runtime.process.* { *; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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")!!
|
||||
@@ -0,0 +1,5 @@
|
||||
package app.revanced.manager
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class ManagerFileProvider : FileProvider()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.koin.dsl.module
|
||||
|
||||
val repositoryModule = module {
|
||||
singleOf(::ReVancedAPI)
|
||||
singleOf(::ManagerUpdateRepository)
|
||||
singleOf(::AnnouncementRepository)
|
||||
singleOf(::Filesystem) {
|
||||
createdAtStart()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("`", ""),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user