diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8683c1a76..5e29853e5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,6 @@ plugins { alias(libs.plugins.jetbrains.kotlin.parcelize) alias(libs.plugins.jetbrains.kotlin.serialization) alias(libs.plugins.google.ksp) - alias(libs.plugins.androidx.navigation) alias(libs.plugins.ktlint) alias(libs.plugins.rikka.tools.refine.plugin) alias(libs.plugins.hilt.android.plugin) @@ -128,23 +127,25 @@ configure { create("vanilla") { isDefault = true dimension = "device" + buildConfigField("Boolean", "SHOW_ANONYMOUS_LOGIN", "true") } create("huawei") { dimension = "device" versionNameSuffix = "-hw" + buildConfigField("Boolean", "SHOW_ANONYMOUS_LOGIN", "false") } // This flavor is only for preloaded devices / users who push the app to system create("preload") { dimension = "device" versionNameSuffix = "-preload" + buildConfigField("Boolean", "SHOW_ANONYMOUS_LOGIN", "true") } } buildFeatures { buildConfig = true - viewBinding = true aidl = true compose = true } @@ -193,8 +194,6 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.navigation3) implementation(libs.androidx.preference.ktx) - implementation(libs.androidx.swiperefreshlayout) - implementation(libs.androidx.viewpager2) implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.paging.runtime) @@ -205,13 +204,12 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) implementation(libs.kotlinx.serialization.json) - implementation(libs.androidx.navigation.fragment.ktx) - implementation(libs.androidx.navigation.ui.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.material3) + implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) @@ -225,13 +223,6 @@ dependencies { implementation(libs.coil.compose) implementation(libs.coil.network) - // Shimmer - implementation(libs.facebook.shimmer) - - // Epoxy - implementation(libs.airbnb.epoxy.android) - ksp(libs.airbnb.epoxy.processor) - // HTTP Clients implementation(libs.squareup.okhttp) diff --git a/app/schemas/com.aurora.store.data.room.AuroraDatabase/7.json b/app/schemas/com.aurora.store.data.room.AuroraDatabase/7.json new file mode 100644 index 000000000..a011f8d89 --- /dev/null +++ b/app/schemas/com.aurora.store.data.room.AuroraDatabase/7.json @@ -0,0 +1,289 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "2fad890b7c37341bc2b26ef023b76124", + "entities": [ + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `size` INTEGER NOT NULL, `id` INTEGER NOT NULL, `downloadStatus` TEXT NOT NULL, `progress` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `timeRemaining` INTEGER NOT NULL, `totalFiles` INTEGER NOT NULL, `downloadedFiles` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `downloadedAt` INTEGER NOT NULL, `requiresGMS` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offerType", + "columnName": "offerType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "isInstalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "downloadStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeRemaining", + "columnName": "timeRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalFiles", + "columnName": "totalFiles", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedFiles", + "columnName": "downloadedFiles", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileList", + "columnName": "fileList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharedLibs", + "columnName": "sharedLibs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetSdk", + "columnName": "targetSdk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresGMS", + "columnName": "requiresGMS", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "favourite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `added` INTEGER NOT NULL, `mode` TEXT NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "update", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `versionName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `changelog` TEXT NOT NULL, `id` INTEGER NOT NULL, `developerName` TEXT NOT NULL, `size` INTEGER NOT NULL, `updatedOn` TEXT NOT NULL, `hasValidCert` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `isIncompatible` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionName", + "columnName": "versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changelog", + "columnName": "changelog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developerName", + "columnName": "developerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedOn", + "columnName": "updatedOn", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasValidCert", + "columnName": "hasValidCert", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offerType", + "columnName": "offerType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileList", + "columnName": "fileList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharedLibs", + "columnName": "sharedLibs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetSdk", + "columnName": "targetSdk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIncompatible", + "columnName": "isIncompatible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2fad890b7c37341bc2b26ef023b76124')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.aurora.store.data.room.AuroraDatabase/8.json b/app/schemas/com.aurora.store.data.room.AuroraDatabase/8.json new file mode 100644 index 000000000..0810dcc35 --- /dev/null +++ b/app/schemas/com.aurora.store.data.room.AuroraDatabase/8.json @@ -0,0 +1,312 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "e21f5fa9464526f3ddfe0a7f58119d1a", + "entities": [ + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `isInstalled` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `size` INTEGER NOT NULL, `id` INTEGER NOT NULL, `downloadStatus` TEXT NOT NULL, `progress` INTEGER NOT NULL, `speed` INTEGER NOT NULL, `timeRemaining` INTEGER NOT NULL, `totalFiles` INTEGER NOT NULL, `downloadedFiles` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `downloadedAt` INTEGER NOT NULL, `requiresGMS` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offerType", + "columnName": "offerType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstalled", + "columnName": "isInstalled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "downloadStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeRemaining", + "columnName": "timeRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalFiles", + "columnName": "totalFiles", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedFiles", + "columnName": "downloadedFiles", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileList", + "columnName": "fileList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharedLibs", + "columnName": "sharedLibs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetSdk", + "columnName": "targetSdk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadedAt", + "columnName": "downloadedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresGMS", + "columnName": "requiresGMS", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "favourite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `added` INTEGER NOT NULL, `mode` TEXT NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "update", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `versionCode` INTEGER NOT NULL, `versionName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `iconURL` TEXT NOT NULL, `changelog` TEXT NOT NULL, `id` INTEGER NOT NULL, `developerName` TEXT NOT NULL, `size` INTEGER NOT NULL, `updatedOn` TEXT NOT NULL, `hasValidCert` INTEGER NOT NULL, `offerType` INTEGER NOT NULL, `fileList` TEXT NOT NULL, `sharedLibs` TEXT NOT NULL, `targetSdk` INTEGER NOT NULL, `isIncompatible` INTEGER NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionCode", + "columnName": "versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionName", + "columnName": "versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconURL", + "columnName": "iconURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "changelog", + "columnName": "changelog", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "developerName", + "columnName": "developerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedOn", + "columnName": "updatedOn", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasValidCert", + "columnName": "hasValidCert", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offerType", + "columnName": "offerType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileList", + "columnName": "fileList", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sharedLibs", + "columnName": "sharedLibs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetSdk", + "columnName": "targetSdk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIncompatible", + "columnName": "isIncompatible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "ignored_update", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoredVersionCode` INTEGER, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ignoredVersionCode", + "columnName": "ignoredVersionCode", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e21f5fa9464526f3ddfe0a7f58119d1a')" + ] + } +} \ No newline at end of file diff --git a/app/src/huawei/java/com/aurora/store/view/ui/splash/SplashFragment.kt b/app/src/huawei/java/com/aurora/store/view/ui/splash/SplashFragment.kt deleted file mode 100644 index 2b5f789be..000000000 --- a/app/src/huawei/java/com/aurora/store/view/ui/splash/SplashFragment.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.splash - -import com.aurora.extensions.hide -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class SplashFragment : BaseFlavouredSplashFragment() { - override fun attachActions() { - super.attachActions() - - binding.btnAnonymous.hide() - } - - override fun resetActions() { - super.resetActions() - - binding.btnAnonymous.hide() - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7d15f5ec..ee020d093 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,9 +82,9 @@ tools:targetApi="tiramisu"> + android:windowSoftInputMode="adjustResize"> @@ -103,16 +103,28 @@ + + - + + + + + + + + + + + + + + + + + - - - UI.TV @@ -50,11 +50,45 @@ class ComposeActivity : ComponentActivity() { } setContent { - CompositionLocalProvider(LocalUI provides localUI) { + val networkStatus by networkProvider.status.collectAsStateWithLifecycle( + initialValue = NetworkStatus.AVAILABLE + ) + CompositionLocalProvider( + LocalUI provides localUI, + LocalNetworkStatus provides networkStatus + ) { AuroraTheme { NavDisplay(startDestination = startDestination) } } } } + + private fun resolveStartDestination(): Screen { + // Parcel-based navigation (e.g. from NotificationUtil) + IntentCompat.getParcelableExtra(intent, Screen.PARCEL_KEY, Screen::class.java) + ?.let { return it } + + // Deep links via ACTION_VIEW + if (intent.action == Intent.ACTION_VIEW) { + val data = intent.data + val path = data?.path.orEmpty() + val id = data?.getQueryParameter("id") + return when { + id != null && path.contains("/apps/dev") -> Screen.DevProfile(id) + id != null -> Screen.AppDetails(id) + else -> defaultStart() + } + } + + // SEND / SHOW_APP_INFO — getPackageName() handles both + intent.getPackageName()?.let { return Screen.AppDetails(it) } + + return defaultStart() + } + + private fun defaultStart(): Screen = when { + !Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -> Screen.Onboarding + else -> Screen.Splash + } } diff --git a/app/src/main/java/com/aurora/store/MainActivity.kt b/app/src/main/java/com/aurora/store/MainActivity.kt deleted file mode 100644 index 683f22517..000000000 --- a/app/src/main/java/com/aurora/store/MainActivity.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * Copyright (C) 2022, The Calyx Institute - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store - -import android.os.Bundle -import android.view.View -import androidx.activity.addCallback -import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat.Type.displayCutout -import androidx.core.view.WindowInsetsCompat.Type.ime -import androidx.core.view.WindowInsetsCompat.Type.systemBars -import androidx.lifecycle.lifecycleScope -import androidx.navigation.FloatingWindow -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.setupWithNavController -import com.aurora.store.data.model.NetworkStatus -import com.aurora.store.data.receiver.MigrationReceiver -import com.aurora.store.databinding.ActivityMainBinding -import com.aurora.store.util.PackageUtil -import com.aurora.store.util.Preferences -import com.aurora.store.util.Preferences.PREFERENCE_DEFAULT_SELECTED_TAB -import com.aurora.store.view.ui.sheets.NetworkDialogSheet -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class MainActivity : AppCompatActivity() { - - private val viewModel: MainViewModel by viewModels() - - private lateinit var binding: ActivityMainBinding - - // TopLevelFragments - private val topLevelFrags = listOf( - R.id.appsContainerFragment, - R.id.gamesContainerFragment, - R.id.updatesFragment - ) - - override fun onCreate(savedInstanceState: Bundle?) { - // Check and run migrations first if required - // This is needed thanks to OEMs breaking the MY_PACKAGE_REPLACED API - MigrationReceiver.runMigrationsIfRequired(this) - - enableEdgeToEdge() - super.onCreate(savedInstanceState) - - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - // Adjust root view's paddings for edgeToEdge display - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { root, windowInsets -> - val insets = windowInsets.getInsets(systemBars() or displayCutout() or ime()) - root.setPadding(insets.left, insets.top, insets.right, 0) - windowInsets - } - - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - val navController = navHostFragment.navController - - if (!PackageUtil.isTv(this)) { - viewModel.networkProvider.status.onEach { networkStatus -> - when (networkStatus) { - NetworkStatus.AVAILABLE -> { - if (!supportFragmentManager.isDestroyed && isIntroDone()) { - val fragment = supportFragmentManager - .findFragmentByTag(NetworkDialogSheet.TAG) - fragment?.let { - supportFragmentManager.beginTransaction() - .remove(fragment) - .commitAllowingStateLoss() - } - } - } - - NetworkStatus.UNAVAILABLE -> { - if (!supportFragmentManager.isDestroyed && isIntroDone()) { - supportFragmentManager.beginTransaction() - .add(NetworkDialogSheet.newInstance(), NetworkDialogSheet.TAG) - .commitAllowingStateLoss() - } - } - } - }.launchIn(AuroraApp.scope) - } - - binding.navView.setupWithNavController(navController) - - // Handle quick exit from back actions - val defaultTab = when (Preferences.getInteger(this, PREFERENCE_DEFAULT_SELECTED_TAB)) { - 1 -> R.id.gamesContainerFragment - 2 -> R.id.updatesFragment - else -> R.id.appsContainerFragment - } - onBackPressedDispatcher.addCallback(this) { - if (navController.currentDestination?.id in topLevelFrags) { - if (navController.currentDestination?.id == defaultTab) { - finish() - } else { - navController.navigate(defaultTab) - } - } else if (navHostFragment.childFragmentManager.backStackEntryCount == 0) { - // We are on either on onboarding or splash fragment - finish() - } else { - navController.navigateUp() - } - } - - // Handle views on fragments - navController.addOnDestinationChangedListener { _, navDestination, _ -> - if (navDestination !is FloatingWindow) { - when (navDestination.id) { - in topLevelFrags -> binding.navView.visibility = View.VISIBLE - else -> binding.navView.visibility = View.GONE - } - } - } - - // Updates - lifecycleScope.launch { - viewModel.updateHelper.updates.collectLatest { list -> - binding.navView.getOrCreateBadge(R.id.updatesFragment).apply { - isVisible = !list.isNullOrEmpty() - number = list?.size ?: 0 - } - } - } - } - - private fun isIntroDone(): Boolean = Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) -} diff --git a/app/src/main/java/com/aurora/store/MainViewModel.kt b/app/src/main/java/com/aurora/store/MainViewModel.kt index 8d70dbf25..8d2d6513e 100644 --- a/app/src/main/java/com/aurora/store/MainViewModel.kt +++ b/app/src/main/java/com/aurora/store/MainViewModel.kt @@ -7,12 +7,10 @@ package com.aurora.store import androidx.lifecycle.ViewModel import com.aurora.store.data.helper.UpdateHelper -import com.aurora.store.data.providers.NetworkProvider import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - val networkProvider: NetworkProvider, val updateHelper: UpdateHelper ) : ViewModel() diff --git a/app/src/main/java/com/aurora/store/compose/composable/AuroraListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/AuroraListItem.kt new file mode 100644 index 000000000..b89d05413 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/AuroraListItem.kt @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +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.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.res.dimensionResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +/** + * Single shared layout for every list-row in the app. A leading slot (icon, image…), a + * stacked headline + supporting + tertiary text column, and a trailing slot (checkbox, + * button, chip…). [headlineStyle] toggles between data-row (bodyMedium) and settings-row + * (bodyLarge) typography while keeping every other dimension consistent. + */ +@Composable +fun AuroraListItem( + modifier: Modifier = Modifier, + headline: String, + supporting: String? = null, + tertiary: String? = null, + onClick: (() -> Unit)? = null, + enabled: Boolean = true, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, + headlineStyle: TextStyle = MaterialTheme.typography.bodyLarge +) { + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable(enabled = enabled, onClick = onClick) + } else { + Modifier + } + ) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)), + verticalAlignment = Alignment.CenterVertically + ) { + if (leading != null) leading() + Column(modifier = Modifier.weight(1f)) { + Text( + text = headline, + style = headlineStyle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!supporting.isNullOrBlank()) { + Text( + text = supporting, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (!tertiary.isNullOrBlank()) { + Text( + text = tertiary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + if (trailing != null) trailing() + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun AuroraListItemPreview() { + AuroraListItem( + headline = "Example app", + supporting = "com.example.app", + tertiary = "v1.0.0 (100)" + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt index 03960dce2..e8a8cfc4c 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/BlackListItem.kt @@ -8,23 +8,16 @@ package com.aurora.store.compose.composable import android.graphics.Bitmap import android.graphics.Color import androidx.compose.foundation.Image -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.requiredSize import androidx.compose.material3.Checkbox 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.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.core.graphics.drawable.toBitmap @@ -33,18 +26,6 @@ import com.aurora.store.BuildConfig import com.aurora.store.R import com.aurora.store.compose.preview.ThemePreviewProvider -/** - * Composable for displaying package details in a list for blacklisting - * @param modifier The modifier to be applied to the composable - * @param icon Icon for the package - * @param displayName User-readable name of the package - * @param packageName Name of the package - * @param versionName versionName of the package - * @param versionCode versionCode of the package - * @param isChecked Whether the app is blacklisted - * @param isEnabled Whether this app is allowed to be blacklisted - * @param onClick Callback when the composable is clicked - */ @Composable fun BlackListItem( modifier: Modifier = Modifier, @@ -57,50 +38,30 @@ fun BlackListItem( isEnabled: Boolean = true, onClick: () -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(enabled = isEnabled, onClick = onClick) - .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_xsmall) - ), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(modifier = Modifier.weight(1F)) { + AuroraListItem( + modifier = modifier, + headline = displayName, + supporting = packageName, + tertiary = stringResource(R.string.version, versionName, versionCode), + headlineStyle = MaterialTheme.typography.bodyMedium, + onClick = onClick, + enabled = isEnabled, + leading = { Image( bitmap = icon.asImageBitmap(), contentDescription = null, - modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)) + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), + colorFilter = if (isChecked) DESATURATE else null ) - Column( - modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)) - ) { - Text( - text = displayName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = packageName, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(R.string.version, versionName, versionCode), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + }, + trailing = { + Checkbox(checked = isChecked, enabled = isEnabled, onCheckedChange = { onClick() }) } - Checkbox(checked = isChecked, enabled = isEnabled, onCheckedChange = { onClick() }) - } + ) } +private val DESATURATE = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable diff --git a/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt b/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt new file mode 100644 index 000000000..95167ad99 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/CategoryItem.kt @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.gplayapi.data.models.Category +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +@Composable +fun CategoryItem(modifier: Modifier = Modifier, category: Category, onClick: () -> Unit = {}) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_large)) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(category.imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_category)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + ) + + Text( + text = category.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun CategoryItemPreview() { + CategoryItem( + category = Category( + title = "Entertainment", + imageUrl = "" + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/ContainedLoadingIndicator.kt b/app/src/main/java/com/aurora/store/compose/composable/ContainedLoadingIndicator.kt index 0d292e823..c4788c2a4 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/ContainedLoadingIndicator.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/ContainedLoadingIndicator.kt @@ -31,7 +31,7 @@ fun ContainedLoadingIndicator(modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxSize() - .padding(dimensionResource(R.dimen.padding_small)), + .padding(dimensionResource(R.dimen.spacing_small)), contentAlignment = Alignment.Center ) { val description = stringResource(R.string.loading) diff --git a/app/src/main/java/com/aurora/store/compose/composable/DeviceListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/DeviceListItem.kt index 0fb05fbd8..5ee652fe2 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/DeviceListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/DeviceListItem.kt @@ -5,34 +5,13 @@ package com.aurora.store.compose.composable -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.material3.Checkbox -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.res.dimensionResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.aurora.store.R -/** - * Composable to display device details for spoofing in a list - * @param modifier The modifier to be applied to the composable - * @param userReadableName Name of the device, obtained through `UserReadableName` property - * @param manufacturer Name of the device manufacturer, obtained through `Build.MANUFACTURER` property - * @param androidVersionSdk Android version on the device, obtained through `Build.VERSION.SDK_INT` property - * @param platforms Platforms supported on the device, obtained through `Platforms` property - * @param isChecked If the device is selected - * @param onClick Callback when the composable is clicked - */ @Composable fun DeviceListItem( modifier: Modifier = Modifier, @@ -43,34 +22,16 @@ fun DeviceListItem( isChecked: Boolean = false, onClick: () -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = { if (!isChecked) onClick() }) - .padding(dimensionResource(R.dimen.padding_small)), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1F)) { - Text( - text = userReadableName, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(R.string.spoof_property, manufacturer, androidVersionSdk), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = platforms.replace(",\\s*".toRegex(), ", "), - style = MaterialTheme.typography.bodySmall - ) + AuroraListItem( + modifier = modifier, + headline = userReadableName, + supporting = stringResource(R.string.spoof_property, manufacturer, androidVersionSdk), + tertiary = platforms.replace(",\\s*".toRegex(), ", "), + onClick = { if (!isChecked) onClick() }, + trailing = { + Checkbox(checked = isChecked, onCheckedChange = { if (!isChecked) onClick() }) } - Checkbox(checked = isChecked, onCheckedChange = { if (!isChecked) onClick() }) - } + ) } @Preview(showBackground = true) diff --git a/app/src/main/java/com/aurora/store/compose/composable/DispenserListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/DispenserListItem.kt index 20dd4cd57..8b663c2c6 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/DispenserListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/DispenserListItem.kt @@ -5,32 +5,17 @@ package com.aurora.store.compose.composable -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.aurora.store.R -/** - * Composable to display dispenser URL in a list - * @param modifier The modifier to be applied to the composable - * @param url URL of the dispenser - * @param onClick Callback when this URL is clicked - * @param onClear Callback when the clear button is clicked - */ @Composable fun DispenserListItem( modifier: Modifier = Modifier, @@ -38,37 +23,23 @@ fun DispenserListItem( onClick: () -> Unit = {}, onClear: () -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(dimensionResource(R.dimen.padding_small)), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1F), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) - ) { - Icon( - painter = painterResource(R.drawable.ic_server), - contentDescription = null - ) - Text( - text = url, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + AuroraListItem( + modifier = modifier, + headline = url, + onClick = onClick, + leading = { + Icon(painter = painterResource(R.drawable.ic_server), contentDescription = null) + }, + trailing = { + TextButton(onClick = onClear) { + Text( + text = stringResource(R.string.remove), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - TextButton(onClick = onClear) { - Text( - text = stringResource(R.string.remove), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } + ) } @Preview(showBackground = true) diff --git a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt index 216004a32..cedf13fb0 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt @@ -8,36 +8,15 @@ package com.aurora.store.compose.composable import android.text.format.DateUtils import android.text.format.Formatter -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -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.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton 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.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -46,141 +25,73 @@ import com.aurora.store.R import com.aurora.store.compose.composable.app.AnimatedAppIcon import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.theme.colorGreen +import com.aurora.store.compose.theme.colorRed import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.room.download.Download import com.aurora.store.util.CommonUtil.getETAString -import com.aurora.store.util.PackageUtil -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -/** - * Composable to display details of a download in a list - * @param modifier The modifier to be applied to the composable - * @param download [Download] to display - * @param onClick Callback when this composable is clicked - */ @Composable -fun DownloadListItem( - modifier: Modifier = Modifier, - download: Download, - onClick: () -> Unit = {}, - onClear: () -> Unit = {}, - onCancel: () -> Unit = {}, - onExport: () -> Unit = {}, - onInstall: () -> Unit = {}, - isExpanded: Boolean = false -) { - val progress = "${download.progress}%" - val speed = "${Formatter.formatShortFileSize(LocalContext.current, download.speed)}/s" - val eta = getETAString(LocalContext.current, download.timeRemaining) +fun DownloadListItem(modifier: Modifier = Modifier, download: Download, onClick: () -> Unit = {}) { + val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() - var isVisible by remember { mutableStateOf(true) } - var isExpanded by remember { mutableStateOf(isExpanded) } - - fun requestClear() { - coroutineScope.launch { - isVisible = false - delay(300) // Let the animation play - onClear() - } + val statusLine = stringResource(download.status.localized) + val tertiaryLine = if (download.status in DownloadStatus.running) { + val progress = "${download.progress}%" + val speed = "${Formatter.formatShortFileSize(context, download.speed)}/s" + val eta = getETAString(context, download.timeRemaining) + "$progress • $speed • $eta" + } else { + DateUtils.formatDateTime(context, download.downloadedAt, DateUtils.FORMAT_SHOW_DATE) } - @Composable - fun ExpandedMenu() { - val context = LocalContext.current - Box(modifier = modifier) { - IconButton(onClick = { isExpanded = true }) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = stringResource(R.string.menu) - ) - } - DropdownMenu(expanded = isExpanded, onDismissRequest = { isExpanded = false }) { - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_cancel)) }, - onClick = onCancel, - enabled = download.isRunning - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_install)) }, - onClick = onInstall, - enabled = download.canInstall(context) - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_export)) }, - onClick = onExport, - enabled = download.canInstall(context) || - PackageUtil.isInstalled( - context, - download.packageName - ) - ) - DropdownMenuItem( - text = { Text(text = stringResource(R.string.action_clear)) }, - onClick = ::requestClear, - enabled = !download.isRunning - ) - } - } - } - - AnimatedVisibility(visible = isVisible, exit = shrinkVertically() + fadeOut()) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_small) - ), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(modifier = Modifier.weight(1F)) { - AnimatedAppIcon( - modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), - iconUrl = download.iconURL, - inProgress = download.isRunning, - progress = download.progress.toFloat() - ) - Column( - modifier = Modifier - .padding(horizontal = dimensionResource(R.dimen.margin_small)) - ) { - Text( - text = download.displayName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + AuroraListItem( + modifier = modifier, + headline = download.displayName, + supporting = statusLine, + tertiary = tertiaryLine, + headlineStyle = MaterialTheme.typography.bodyMedium, + onClick = onClick, + leading = { + AnimatedAppIcon( + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), + iconUrl = download.iconURL, + inProgress = download.isRunning, + progress = download.progress.toFloat() + ) + }, + trailing = when (download.status) { + DownloadStatus.COMPLETED -> { + { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = stringResource(R.string.download_completed), + tint = colorGreen ) - Text( - text = stringResource(download.status.localized), - style = MaterialTheme.typography.bodySmall - ) - AnimatedContent(targetState = download.status) { status -> - Text( - style = MaterialTheme.typography.bodySmall, - text = if (status in DownloadStatus.running) { - "$progress • $speed • $eta" - } else { - DateUtils.formatDateTime( - LocalContext.current, - download.downloadedAt, - DateUtils.FORMAT_SHOW_DATE - ).toString() - } - ) - } } } - ExpandedMenu() + + DownloadStatus.CANCELLED, DownloadStatus.FAILED + -> { + { + Icon( + painter = painterResource(R.drawable.ic_cancel), + contentDescription = stringResource(R.string.download_canceled), + tint = colorRed + ) + } + } + + else -> { + null + } } - } + ) } @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable private fun DownloadListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { - DownloadListItem(download = Download.fromApp(app), isExpanded = true) + DownloadListItem(download = Download.fromApp(app)) } diff --git a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt index ac57d8759..7e357fa12 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/FavouriteListItem.kt @@ -6,28 +6,12 @@ package com.aurora.store.compose.composable import android.text.format.DateUtils -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -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.requiredSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.rememberCoroutineScope -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.layout.ContentScale @@ -35,7 +19,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -47,16 +30,7 @@ import com.aurora.store.R import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.room.favourite.Favourite -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -/** - * Composable to display a favourite app in a list - * @param modifier The modifier to be applied to the composable - * @param favourite A [Favourite] app to display - * @param onClick Callback when this composable is clicked - * @param onClear Callback when the favourite button is clicked to remove the app from favourites - */ @Composable fun FavouriteListItem( modifier: Modifier = Modifier, @@ -64,35 +38,22 @@ fun FavouriteListItem( onClick: () -> Unit = {}, onClear: () -> Unit = {} ) { - val coroutineScope = rememberCoroutineScope() - var isVisible by remember { mutableStateOf(true) } - - fun requestClear() { - coroutineScope.launch { - isVisible = false - delay(300) // Let the animation play - onClear() - } - } - - AnimatedVisibility( - visible = isVisible, - exit = shrinkVertically() + fadeOut() - ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_xsmall) - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(modifier = Modifier.weight(1F)) { + val context = LocalContext.current + RemovableListItem(onRemove = onClear) { triggerRemove -> + AuroraListItem( + modifier = modifier, + headline = favourite.displayName, + supporting = favourite.packageName, + tertiary = DateUtils.formatDateTime( + context, + favourite.added, + DateUtils.FORMAT_SHOW_DATE + ), + headlineStyle = MaterialTheme.typography.bodyMedium, + onClick = onClick, + leading = { AsyncImage( - model = ImageRequest.Builder(LocalContext.current) + model = ImageRequest.Builder(context) .data(favourite.iconURL) .crossfade(true) .build(), @@ -102,40 +63,16 @@ fun FavouriteListItem( .requiredSize(dimensionResource(R.dimen.icon_size_medium)) .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) ) - Column( - modifier = Modifier.padding( - horizontal = dimensionResource(R.dimen.margin_small) - ) - ) { - Text( - text = favourite.displayName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = favourite.packageName, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = DateUtils.formatDateTime( - LocalContext.current, - favourite.added, - DateUtils.FORMAT_SHOW_DATE - ), - style = MaterialTheme.typography.bodySmall + }, + trailing = { + IconButton(onClick = triggerRemove) { + Icon( + painter = painterResource(R.drawable.ic_favorite_checked), + contentDescription = stringResource(R.string.action_favourite) ) } } - IconButton(onClick = { requestClear() }) { - Icon( - painter = painterResource(R.drawable.ic_favorite_checked), - contentDescription = stringResource(R.string.action_favourite) - ) - } - } + ) } } diff --git a/app/src/main/java/com/aurora/store/compose/composable/Info.kt b/app/src/main/java/com/aurora/store/compose/composable/Info.kt index 05ddee912..448d6d96a 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/Info.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/Info.kt @@ -57,10 +57,10 @@ fun Info( .fillMaxWidth() .clickable(onClick = { if (onClick != null) onClick() }, enabled = onClick != null) .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_xxsmall) + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) ), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)), verticalAlignment = Alignment.CenterVertically ) { if (painter != null) Icon(painter = painter, contentDescription = null) diff --git a/app/src/main/java/com/aurora/store/compose/composable/InstallerListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/InstallerListItem.kt index 449a9baa7..ecf4fa97f 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/InstallerListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/InstallerListItem.kt @@ -5,35 +5,16 @@ package com.aurora.store.compose.composable -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.material3.MaterialTheme import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import com.aurora.store.R import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.installer.SessionInstaller import com.aurora.store.data.model.InstallerInfo -/** - * Composable to display installer details in a list - * @param modifier The modifier to be applied to the composable - * @param installerInfo A [InstallerInfo] object to display details - * @param isSelected Whether this installer is selected - * @param onClick Callback when this composable is clicked - */ @Composable fun InstallerListItem( modifier: Modifier = Modifier, @@ -41,34 +22,14 @@ fun InstallerListItem( isSelected: Boolean = false, onClick: () -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(dimensionResource(R.dimen.padding_small)), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1F)) { - Text( - text = stringResource(installerInfo.title), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(installerInfo.subtitle), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(installerInfo.description), - style = MaterialTheme.typography.bodySmall - ) - } - RadioButton(selected = isSelected, onClick = onClick) - } + AuroraListItem( + modifier = modifier, + headline = stringResource(installerInfo.title), + supporting = stringResource(installerInfo.subtitle), + tertiary = stringResource(installerInfo.description), + onClick = onClick, + trailing = { RadioButton(selected = isSelected, onClick = onClick) } + ) } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt index b6869af4c..443950ec5 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt @@ -6,21 +6,9 @@ package com.aurora.store.compose.composable -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.foundation.layout.requiredSize -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider 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 @@ -29,7 +17,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import coil3.compose.AsyncImage @@ -38,12 +25,6 @@ import com.aurora.store.R import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.model.Link -/** - * Composable to show link details in a list - * @param modifier The modifier to be applied to the composable - * @param link [Link] to show details - * @param onClick Callback when the composable is clicked - */ @Composable fun LinkListItem( modifier: Modifier = Modifier, @@ -51,46 +32,25 @@ fun LinkListItem( onClick: () -> Unit = {}, iconTint: Color? = null ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(dimensionResource(R.dimen.padding_small)), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(link.icon) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .requiredSize(dimensionResource(R.dimen.icon_size_default)) - .clip(CircleShape), - colorFilter = if (iconTint != null) ColorFilter.tint(color = iconTint) else null - ) - VerticalDivider( - modifier = Modifier - .padding(horizontal = dimensionResource(R.dimen.margin_large)) - .width(dimensionResource(R.dimen.padding_xxsmall)) - .height(dimensionResource(R.dimen.height_header)) - ) - Column { - Text( - text = link.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = link.subtitle, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis + AuroraListItem( + modifier = modifier, + headline = link.title, + supporting = link.subtitle, + onClick = onClick, + leading = { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(link.icon) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_default)) + .clip(CircleShape), + colorFilter = if (iconTint != null) ColorFilter.tint(color = iconTint) else null ) } - } + ) } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/compose/composable/LocaleListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/LocaleListItem.kt index cebc413d8..63d8a299f 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/LocaleListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/LocaleListItem.kt @@ -5,32 +5,12 @@ package com.aurora.store.compose.composable -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.material3.Checkbox -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.res.dimensionResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import com.aurora.store.R import java.util.Locale -/** - * Composable to display locale details in a list - * @param modifier The modifier to be applied to the composable - * @param displayName Display name of the locale - * @param displayLanguage Display name of the language in the locale - * @param isChecked Whether the locale is checked/selected - * @param onClick Callback when the composable is clicked - */ @Composable fun LocaleListItem( modifier: Modifier = Modifier, @@ -39,30 +19,15 @@ fun LocaleListItem( isChecked: Boolean = false, onClick: () -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = { if (!isChecked) onClick() }) - .padding(dimensionResource(R.dimen.padding_small)), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1F)) { - Text( - text = displayName, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = displayLanguage, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + AuroraListItem( + modifier = modifier, + headline = displayName, + supporting = displayLanguage, + onClick = { if (!isChecked) onClick() }, + trailing = { + Checkbox(checked = isChecked, onCheckedChange = { if (!isChecked) onClick() }) } - Checkbox(checked = isChecked, onCheckedChange = { if (!isChecked) onClick() }) - } + ) } @Preview(showBackground = true) diff --git a/app/src/main/java/com/aurora/store/compose/composable/Logo.kt b/app/src/main/java/com/aurora/store/compose/composable/Logo.kt index ad51a8658..e66384e19 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/Logo.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/Logo.kt @@ -36,7 +36,7 @@ fun Logo(modifier: Modifier = Modifier) { Column( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.margin_small), + dimensionResource(R.dimen.spacing_small), Alignment.CenterVertically ), horizontalAlignment = Alignment.CenterHorizontally diff --git a/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt b/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt index cd5cb855a..188efd7cb 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt @@ -91,13 +91,13 @@ fun MicroG( modifier = modifier .fillMaxSize() .padding( - horizontal = dimensionResource(R.dimen.padding_small), - vertical = dimensionResource(R.dimen.padding_xxsmall) + horizontal = dimensionResource(R.dimen.spacing_small), + vertical = dimensionResource(R.dimen.spacing_xsmall) ), verticalArrangement = Arrangement.SpaceBetween ) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) ) { Text( text = stringResource(R.string.onboarding_gms_microg), @@ -116,8 +116,8 @@ fun MicroG( Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = dimensionResource(R.dimen.padding_large)), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + .padding(vertical = dimensionResource(R.dimen.spacing_large)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) ) { uiState.items.fastForEach { item -> MicroGItemRow(item = item) @@ -127,7 +127,7 @@ fun MicroG( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.padding_small) + dimensionResource(R.dimen.spacing_small) ) ) { Checkbox( @@ -218,8 +218,8 @@ private fun MicroGItemRow(item: ExternalItem) { modifier = Modifier .fillMaxWidth() .padding( - horizontal = dimensionResource(R.dimen.padding_small), - vertical = dimensionResource(R.dimen.padding_xsmall) + horizontal = dimensionResource(R.dimen.spacing_small), + vertical = dimensionResource(R.dimen.spacing_xsmall) ), verticalAlignment = Alignment.CenterVertically ) { @@ -232,7 +232,7 @@ private fun MicroGItemRow(item: ExternalItem) { Column( modifier = Modifier .weight(1F) - .padding(horizontal = dimensionResource(R.dimen.margin_small)) + .padding(horizontal = dimensionResource(R.dimen.spacing_small)) ) { Text( text = item.displayName, diff --git a/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt b/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt index 5793ca70a..680015acb 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/PageIndicator.kt @@ -43,7 +43,7 @@ fun PageIndicator(modifier: Modifier = Modifier, totalPages: Int, currentPage: I modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.margin_xsmall), + dimensionResource(R.dimen.spacing_xsmall), Alignment.CenterHorizontally ) ) { @@ -60,7 +60,7 @@ fun PageIndicator(modifier: Modifier = Modifier, totalPages: Int, currentPage: I ) val size by animateDpAsState( targetValue = if (isSelected) { - dimensionResource(R.dimen.radius_normal) + dimensionResource(R.dimen.radius_medium) } else { dimensionResource(R.dimen.radius_small) }, diff --git a/app/src/main/java/com/aurora/store/compose/composable/PermissionList.kt b/app/src/main/java/com/aurora/store/compose/composable/PermissionList.kt index c428b82af..d7d96de19 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/PermissionList.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/PermissionList.kt @@ -133,7 +133,7 @@ fun PermissionList( LazyColumn( modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) ) { if (header != null) stickyHeader(content = header) diff --git a/app/src/main/java/com/aurora/store/compose/composable/PermissionListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/PermissionListItem.kt index d5c941756..0bfec9df5 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/PermissionListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/PermissionListItem.kt @@ -5,18 +5,10 @@ package com.aurora.store.compose.composable -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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -26,49 +18,30 @@ import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.model.Permission import com.aurora.store.data.model.PermissionType -/** - * Composable to display permission details in a list - * @param modifier The modifier to be applied to the composable - * @param permission [Permission] to display - * @param onAction Callback when the user clicks the action button - */ @Composable fun PermissionListItem( modifier: Modifier = Modifier, permission: Permission, onAction: () -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .padding(dimensionResource(R.dimen.padding_small)), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column(modifier = Modifier.weight(1F)) { - Text( - text = permission.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = permission.subtitle, - style = MaterialTheme.typography.bodySmall - ) + AuroraListItem( + modifier = modifier, + headline = permission.title, + supporting = permission.subtitle, + trailing = { + TextButton(onClick = onAction, enabled = !permission.isGranted) { + Text( + text = if (permission.isGranted) { + stringResource(R.string.action_granted) + } else { + stringResource(R.string.action_grant) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - TextButton(onClick = onAction, enabled = !permission.isGranted) { - Text( - text = if (permission.isGranted) { - stringResource(R.string.action_granted) - } else { - stringResource(R.string.action_grant) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } + ) } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/compose/composable/Error.kt b/app/src/main/java/com/aurora/store/compose/composable/Placeholder.kt similarity index 64% rename from app/src/main/java/com/aurora/store/compose/composable/Error.kt rename to app/src/main/java/com/aurora/store/compose/composable/Placeholder.kt index 47199e886..9abf5c163 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/Error.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/Placeholder.kt @@ -1,4 +1,5 @@ /* + * SPDX-FileCopyrightText: 2026 Aurora OSS * SPDX-FileCopyrightText: 2025 The Calyx Institute * SPDX-License-Identifier: GPL-3.0-or-later */ @@ -8,11 +9,11 @@ package com.aurora.store.compose.composable 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.requiredSize -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.size +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 @@ -29,27 +30,23 @@ import com.aurora.store.R import com.aurora.store.compose.preview.ThemePreviewProvider /** - * Composable to show error message that fills all available screen - * @param modifier The modifier to be applied to the composable - * @param painter Painter to draw the icon - * @param message Message for error - * @param actionMessage Message to show on action button; defaults to null with button not visible - * @param onAction Callback when action button is clicked + * Full-screen empty/error placeholder: centered icon + message and an optional action button. + * Replaces the previous EmptyState and Error composables. */ @Composable -fun Error( +fun Placeholder( modifier: Modifier = Modifier, painter: Painter, message: String, - actionMessage: String? = null, + actionLabel: String? = null, onAction: (() -> Unit)? = null ) { Column( modifier = modifier .fillMaxSize() - .padding(dimensionResource(R.dimen.padding_medium)), + .padding(dimensionResource(R.dimen.spacing_medium)), verticalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.margin_small), + dimensionResource(R.dimen.spacing_small), Alignment.CenterVertically ), horizontalAlignment = Alignment.CenterHorizontally @@ -57,18 +54,18 @@ fun Error( Icon( painter = painter, contentDescription = null, - modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size)) + modifier = Modifier.size(dimensionResource(R.dimen.icon_size_medium)), + tint = MaterialTheme.colorScheme.primary ) Text( - modifier = Modifier.fillMaxWidth(), text = message, + style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) - - if (actionMessage != null) { - Button(onClick = { if (onAction != null) onAction() }, enabled = onAction != null) { + if (actionLabel != null && onAction != null) { + FilledTonalButton(onClick = onAction) { Text( - text = actionMessage, + text = actionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -80,10 +77,11 @@ fun Error( @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable -private fun ErrorPreview() { - Error( +private fun PlaceholderPreview() { + Placeholder( painter = painterResource(R.drawable.ic_updates), message = stringResource(R.string.details_no_updates), - actionMessage = stringResource(R.string.check_updates) + actionLabel = stringResource(R.string.check_updates), + onAction = {} ) } diff --git a/app/src/main/java/com/aurora/store/compose/composable/RemovableListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/RemovableListItem.kt new file mode 100644 index 000000000..60d104967 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/RemovableListItem.kt @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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 kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val REMOVE_ANIM_DURATION_MS = 300L + +/** + * Wraps [content] in an AnimatedVisibility container that shrinks vertically and fades out + * before invoking [onRemove]. The content lambda receives a trigger callback that, when + * invoked, plays the exit animation and then calls [onRemove]. + */ +@Composable +fun RemovableListItem( + onRemove: () -> Unit, + content: @Composable (triggerRemove: () -> Unit) -> Unit +) { + val scope = rememberCoroutineScope() + var visible by remember { mutableStateOf(true) } + AnimatedVisibility(visible = visible, exit = shrinkVertically() + fadeOut()) { + content { + scope.launch { + visible = false + delay(REMOVE_ANIM_DURATION_MS) + onRemove() + } + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt index 3597f508d..f73d9a191 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt @@ -36,8 +36,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.aurora.store.R @@ -48,7 +48,6 @@ import kotlinx.coroutines.launch fun ScrollHint( listState: LazyListState, modifier: Modifier = Modifier, - bottomPadding: Dp = 72.dp, // keep above bottom bar autoHideOnScroll: Boolean = true, enableBounce: Boolean = true, onClickScrollOffset: Float = 300f @@ -88,7 +87,7 @@ fun ScrollHint( visible = visible, modifier = modifier .fillMaxWidth() - .padding(bottom = bottomPadding), + .padding(bottom = dimensionResource(R.dimen.scroll_hint_padding)), enter = fadeIn() + slideInVertically { it / 2 }, exit = fadeOut() ) { diff --git a/app/src/main/java/com/aurora/store/compose/composable/SearchSuggestionListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/SearchSuggestionListItem.kt index 8a95d4784..959b07ea6 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/SearchSuggestionListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/SearchSuggestionListItem.kt @@ -5,24 +5,15 @@ package com.aurora.store.compose.composable -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import coil3.compose.AsyncImage @@ -32,13 +23,6 @@ import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.store.R import com.aurora.store.compose.preview.ThemePreviewProvider -/** - * Composable for displaying search suggestions in a list - * @param modifier The modifier to be applied to the composable - * @param searchSuggestEntry A [SearchSuggestEntry] to display search suggestion - * @param onClick Callback when this composable is clicked - * @param onAction Callback when action button is clicked - */ @Composable fun SearchSuggestionListItem( modifier: Modifier = Modifier, @@ -46,25 +30,11 @@ fun SearchSuggestionListItem( onClick: (query: String) -> Unit = {}, onAction: (query: String) -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = { onClick(searchSuggestEntry.title) }) - .padding( - top = dimensionResource(R.dimen.padding_xsmall), - bottom = dimensionResource(R.dimen.padding_xsmall), - start = dimensionResource(R.dimen.padding_large), - end = dimensionResource(R.dimen.padding_xsmall) - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - modifier = Modifier - .weight(1F) - .clickable(onClick = { onClick(searchSuggestEntry.title) }), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) - ) { + AuroraListItem( + modifier = modifier, + headline = searchSuggestEntry.title, + onClick = { onClick(searchSuggestEntry.title) }, + leading = { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data( @@ -81,20 +51,16 @@ fun SearchSuggestionListItem( contentScale = ContentScale.Crop, modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_default)) ) - Text( - text = searchSuggestEntry.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + }, + trailing = { + IconButton(onClick = { onAction(searchSuggestEntry.title) }) { + Icon( + painter = painterResource(R.drawable.ic_search_append), + contentDescription = null + ) + } } - IconButton(onClick = { onAction(searchSuggestEntry.title) }) { - Icon( - painter = painterResource(R.drawable.ic_search_append), - contentDescription = null - ) - } - } + ) } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/compose/composable/Header.kt b/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt similarity index 53% rename from app/src/main/java/com/aurora/store/compose/composable/Header.kt rename to app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt index 1bf179455..9957c1d7b 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/Header.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/SectionHeader.kt @@ -12,15 +12,16 @@ 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.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -28,34 +29,30 @@ import com.aurora.store.R import com.aurora.store.compose.preview.ThemePreviewProvider /** - * Composable to display sticky header in list - * @param modifier The modifier to be applied to the composable - * @param title Title to display - * @param subtitle Optional subtitle to display - * @param onClick Callback when this composable is clicked - * @see TextDividerComposable + * Section header row used throughout the app. Title with optional subtitle, an optional + * trailing slot, and the whole row becomes clickable when [onClick] is non-null. When + * [trailing] is null and [onClick] is set, a default right-chevron is shown. */ @Composable -fun Header( +fun SectionHeader( modifier: Modifier = Modifier, title: String, subtitle: String? = null, - onClick: (() -> Unit)? = null + onClick: (() -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null ) { Row( modifier = modifier .fillMaxWidth() - .clickable(onClick = { if (onClick != null) onClick() }, enabled = onClick != null) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_xsmall) + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) ), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.weight(1F) - ) { + Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.titleMedium, @@ -65,15 +62,20 @@ fun Header( if (!subtitle.isNullOrBlank()) { Text( text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } } - if (onClick != null) { - Icon( + when { + trailing != null -> trailing() + onClick != null -> Icon( painter = painterResource(R.drawable.ic_arrow_right), - contentDescription = null + contentDescription = null, + modifier = Modifier.size(dimensionResource(R.dimen.icon_size_default)), + tint = MaterialTheme.colorScheme.primary ) } } @@ -82,10 +84,23 @@ fun Header( @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable -private fun HeaderPreview() { - Header( - title = stringResource(R.string.details_privacy), - subtitle = stringResource(R.string.exodus_powered), - onClick = {} +private fun SectionHeaderPreview() { + SectionHeader(title = "Top Charts", onClick = {}) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun SectionHeaderWithSubtitlePreview() { + SectionHeader(title = "Permissions", subtitle = "3 requested", onClick = {}) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun SectionHeaderWithActionPreview() { + SectionHeader( + title = "3 updates available", + trailing = { TextButton(onClick = {}) { Text("Update all") } } ) } diff --git a/app/src/main/java/com/aurora/store/compose/composable/Shimmer.kt b/app/src/main/java/com/aurora/store/compose/composable/Shimmer.kt new file mode 100644 index 000000000..e604b78f0 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/Shimmer.kt @@ -0,0 +1,241 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +@Composable +fun shimmerBrush(): Brush { + val base = MaterialTheme.colorScheme.onSurface + val colors = listOf( + base.copy(alpha = 0.08f), + base.copy(alpha = 0.20f), + base.copy(alpha = 0.08f) + ) + val transition = rememberInfiniteTransition(label = "shimmer") + val offset by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_offset" + ) + val x = offset * 1200f + return Brush.linearGradient( + colors = colors, + start = Offset(x - 400f, 0f), + end = Offset(x, 400f) + ) +} + +@Composable +private fun ShimmerBlock(modifier: Modifier, radiusRes: Int = R.dimen.radius_small) { + Box( + modifier = modifier + .clip(RoundedCornerShape(dimensionResource(radiusRes))) + .background(shimmerBrush()) + ) +} + +@Composable +private fun ShimmerTextStack(modifier: Modifier = Modifier, widthFractions: List) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) + ) { + widthFractions.forEach { fraction -> + ShimmerBlock( + modifier = Modifier + .fillMaxWidth(fraction) + .height(14.dp) + ) + } + } +} + +@Composable +private fun ShimmerListRow(showTrailing: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_small), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + ShimmerBlock( + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)) + ) + ShimmerTextStack( + modifier = Modifier.weight(1f), + widthFractions = listOf(0.7f, 0.5f, 0.5f) + ) + if (showTrailing) { + ShimmerBlock( + modifier = Modifier + .width(80.dp) + .height(36.dp), + radiusRes = R.dimen.radius_large + ) + } + } +} + +@Composable +internal fun ShimmerAppRow() { + ShimmerListRow(showTrailing = false) +} + +@Composable +internal fun ShimmerUpdateItem() { + ShimmerListRow(showTrailing = true) +} + +@Composable +internal fun ShimmerCategoryRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_large)) + ) { + ShimmerBlock( + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_category)) + ) + ShimmerBlock( + modifier = Modifier + .weight(1f) + .height(24.dp) + ) + } +} + +@Composable +internal fun ShimmerCarouselSection() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(all = dimensionResource(R.dimen.spacing_small)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + ShimmerBlock( + modifier = Modifier + .fillMaxWidth(0.42f) + .height(16.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + repeat(5) { + Column( + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_xsmall) + ) + ) { + ShimmerBlock( + modifier = Modifier.size(dimensionResource(R.dimen.icon_size_cluster)), + radiusRes = R.dimen.radius_medium + ) + ShimmerBlock( + modifier = Modifier + .width(dimensionResource(R.dimen.icon_size_cluster) * 0.75f) + .height(11.dp) + ) + ShimmerBlock( + modifier = Modifier + .width(dimensionResource(R.dimen.icon_size_cluster) * 0.5f) + .height(11.dp) + ) + } + } + } + } +} + +@Composable +internal fun ShimmerAppListItem() { + ShimmerBlock( + modifier = Modifier + .padding(dimensionResource(R.dimen.spacing_xsmall)) + .size(dimensionResource(R.dimen.icon_size_cluster)), + radiusRes = R.dimen.radius_medium + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerCategoryRowPreview() { + ShimmerCategoryRow() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerAppRowPreview() { + ShimmerAppRow() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerUpdateItemPreview() { + ShimmerUpdateItem() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerCarouselSectionPreview() { + ShimmerCarouselSection() +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun ShimmerAppListItemPreview() { + ShimmerAppListItem() +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt b/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt new file mode 100644 index 000000000..e77618b29 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/StreamCarousel.kt @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster +import com.aurora.store.R +import com.aurora.store.compose.composable.app.AppListItem +import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.preview.ThemePreviewProvider +import kotlinx.coroutines.flow.distinctUntilChanged + +private const val LOAD_MORE_THRESHOLD = 2 + +@Composable +fun StreamCarousel( + modifier: Modifier = Modifier, + streamBundle: StreamBundle?, + filterSingleAppClusters: Boolean = true, + lazyListState: LazyListState = rememberLazyListState(), + onHeaderClick: (StreamCluster) -> Unit = {}, + onAppClick: (App) -> Unit = {}, + onClusterScrolled: (StreamCluster) -> Unit = {}, + onScrolledToEnd: () -> Unit = {} +) { + val bundleLoaded = streamBundle != null + LaunchedEffect(lazyListState, bundleLoaded) { + snapshotFlow { + val last = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + val total = lazyListState.layoutInfo.totalItemsCount + last >= total - LOAD_MORE_THRESHOLD + }.distinctUntilChanged().collect { reachedEnd -> + if (reachedEnd && bundleLoaded) onScrolledToEnd() + } + } + + if (streamBundle == null) { + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) + ) { + items(5) { ShimmerCarouselSection() } + } + return + } + + val clusters = streamBundle.streamClusters.values + .filter { cluster -> + cluster.clusterAppList.isNotEmpty() && + cluster.clusterTitle.isNotBlank() && + (!filterSingleAppClusters || cluster.clusterAppList.size > 1) + } + + if (clusters.isEmpty()) { + Placeholder( + modifier = modifier, + painter = painterResource(R.drawable.ic_apps), + message = stringResource(R.string.no_apps_available) + ) + return + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + verticalArrangement = Arrangement.spacedBy( + dimensionResource( + if (clusters.size == 1) { + R.dimen.spacing_medium + } else { + R.dimen.spacing_xsmall + } + ) + ) + ) { + if (clusters.size == 1) { + val apps = clusters.first().clusterAppList + items(count = apps.size, key = { apps[it].id }) { index -> + LargeAppListItem( + app = apps[index], + onClick = { onAppClick(apps[index]) } + ) + } + } else { + clusters.forEach { cluster -> + item(key = "header_${cluster.id}") { + SectionHeader( + title = cluster.clusterTitle, + onClick = if (cluster.clusterBrowseUrl.isNotBlank()) { + { onHeaderClick(cluster) } + } else { + null + } + ) + } + item(key = "row_${cluster.id}") { + ClusterRow( + cluster = cluster, + onAppClick = onAppClick, + onClusterScrolled = onClusterScrolled + ) + } + } + } + + if (streamBundle.hasNext()) { + item(key = "shimmer_footer") { ShimmerCarouselSection() } + } + } +} + +@Composable +internal fun ClusterRow( + cluster: StreamCluster, + onAppClick: (App) -> Unit = {}, + onClusterScrolled: (StreamCluster) -> Unit = {} +) { + val rowState = rememberLazyListState() + val reachedEnd by remember { + derivedStateOf { + val last = rowState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + val total = rowState.layoutInfo.totalItemsCount + last >= total - LOAD_MORE_THRESHOLD + } + } + + LaunchedEffect(reachedEnd) { + if (reachedEnd && cluster.hasNext()) onClusterScrolled(cluster) + } + + LazyRow( + state = rowState, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.spacing_small)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + itemsIndexed( + items = cluster.clusterAppList, + key = { _, app -> app.packageName } + ) { _, app -> + AppListItem( + app = app, + onClick = { onAppClick(app) } + ) + } + if (cluster.hasNext()) { + item(key = "shimmer_${cluster.id}") { ShimmerAppListItem() } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun StreamCarouselLoadingPreview() { + StreamCarousel(streamBundle = null) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun StreamCarouselEmptyPreview() { + StreamCarousel(streamBundle = StreamBundle.EMPTY) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/TextDividerComposable.kt b/app/src/main/java/com/aurora/store/compose/composable/TextDividerComposable.kt index 320a9df8a..aa261b800 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/TextDividerComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/TextDividerComposable.kt @@ -30,7 +30,7 @@ fun TextDividerComposable(modifier: Modifier = Modifier, title: String) { Text( modifier = modifier .fillMaxWidth() - .padding(dimensionResource(R.dimen.padding_small)), + .padding(dimensionResource(R.dimen.spacing_small)), text = title.uppercase(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt b/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt index c9043064f..3f9ab4713 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/TopAppBar.kt @@ -5,11 +5,15 @@ package com.aurora.store.compose.composable +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -25,7 +29,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider * @param modifier The modifier to be applied to the composable * @param title Title of the screen * @param navigationIcon Icon for the navigation button - * @param onNavigateUp Action when user clicks the navigation icon + * @param showNavigationIcon Whether to show the navigation (back) icon button * @param actions Actions to display on the top app bar (for e.g. menu) */ @Composable @@ -33,15 +37,17 @@ fun TopAppBar( modifier: Modifier = Modifier, title: String? = null, navigationIcon: Painter = painterResource(R.drawable.ic_arrow_back), - onNavigateUp: (() -> Unit)? = null, + showNavigationIcon: Boolean = true, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, actions: @Composable (RowScope.() -> Unit) = {} ) { + val activity = LocalActivity.current as? ComponentActivity TopAppBar( modifier = modifier, title = { if (title != null) Text(text = title) }, navigationIcon = { - if (onNavigateUp != null) { - IconButton(onClick = onNavigateUp) { + if (showNavigationIcon) { + IconButton(onClick = { activity?.onBackPressedDispatcher?.onBackPressed() }) { Icon( painter = navigationIcon, contentDescription = stringResource(R.string.action_back) @@ -49,6 +55,7 @@ fun TopAppBar( } } }, + windowInsets = windowInsets, actions = actions ) } @@ -58,7 +65,6 @@ fun TopAppBar( @Composable private fun TopAppBarPreview() { TopAppBar( - title = stringResource(R.string.title_about), - onNavigateUp = {} + title = stringResource(R.string.title_about) ) } diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt index 860988813..f75de3896 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AnimatedAppIcon.kt @@ -111,7 +111,9 @@ private fun AnimatedAppIconPreview(@PreviewParameter(AppPreviewProvider::class) @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable -private fun AnimatedAppIconPreview(@PreviewParameter(ProgressProvider::class) progress: Float) { +private fun AnimatedAppIconInProgressPreview( + @PreviewParameter(ProgressProvider::class) progress: Float +) { AnimatedAppIcon( modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_large)), iconUrl = "", diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt index fed710407..2d1e75c64 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppListItem.kt @@ -5,21 +5,24 @@ package com.aurora.store.compose.composable.app -import android.text.format.Formatter import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -41,38 +44,38 @@ import com.aurora.store.compose.preview.ThemePreviewProvider */ @Composable fun AppListItem(modifier: Modifier = Modifier, app: App, onClick: () -> Unit = {}) { - val context = LocalContext.current - Column( modifier = modifier .width(dimensionResource(R.dimen.icon_size_cluster)) .clickable(onClick = onClick) - .padding(dimensionResource(R.dimen.padding_xsmall)) + .padding(all = dimensionResource(R.dimen.spacing_xsmall)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_xsmall), + Alignment.CenterVertically + ) ) { AsyncImage( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))), model = ImageRequest.Builder(LocalContext.current) .data(app.iconArtwork.url) .crossfade(true) .build(), contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Crop + + ) + Text( modifier = Modifier - .requiredSize(dimensionResource(R.dimen.icon_size_cluster)) - .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) - ) - Text( + .fillMaxWidth(), text = app.displayName, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelMedium, maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = if (app.size > 0) { - Formatter.formatShortFileSize(context, app.size) - } else { - app.downloadString - }, - style = MaterialTheme.typography.bodySmall + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start ) } } diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt new file mode 100644 index 000000000..4316054fe --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/app/AppUpdateItem.kt @@ -0,0 +1,172 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable.app + +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.data.model.DownloadStatus +import com.aurora.store.data.room.download.Download +import com.aurora.store.data.room.update.Update +import com.aurora.store.util.CommonUtil + +@Composable +fun AppUpdateItem( + modifier: Modifier = Modifier, + update: Update, + download: Download? = null, + onClick: () -> Unit = {}, + onUpdate: () -> Unit = {}, + onCancel: () -> Unit = {}, + onUnignore: (() -> Unit)? = null +) { + val inProgress = download != null && !download.isFinished + val pendingInstall = download?.status == DownloadStatus.COMPLETED + val progress = if (download?.status == DownloadStatus.DOWNLOADING) { + download.progress.toFloat() + } else { + 0f + } + + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) + ), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium))) { + AnimatedAppIcon( + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), + iconUrl = update.iconURL, + inProgress = inProgress || pendingInstall, + progress = progress + ) + } + Spacer(Modifier.width(dimensionResource(R.dimen.spacing_medium))) + Column(modifier = Modifier.weight(1f)) { + Text( + text = update.displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = update.developerName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${update.versionName} • ${CommonUtil.addSiPrefix(update.size)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(dimensionResource(R.dimen.spacing_small))) + when { + onUnignore != null -> { + OutlinedButton(onClick = onUnignore) { + Text(stringResource(R.string.action_unignore)) + } + } + + pendingInstall -> { + OutlinedButton(onClick = {}, enabled = false) { + Text(stringResource(R.string.action_installing)) + } + } + + inProgress -> { + OutlinedButton(onClick = onCancel) { + Text(stringResource(R.string.action_cancel)) + } + } + + else -> { + Button(onClick = onUpdate) { + Text(stringResource(R.string.action_update)) + } + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun AppUpdateItemPreview() { + val update = Update( + packageName = "com.example.app", + versionCode = 100, + versionName = "1.0.0", + displayName = "Example App", + iconURL = "", + changelog = "• Bug fixes
• Performance improvements", + id = 1, + developerName = "Developer", + size = 5_000_000, + updatedOn = "May 2025", + hasValidCert = true, + offerType = 1, + fileList = emptyList(), + sharedLibs = emptyList() + ) + AppUpdateItem(update = update) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun AppUpdateItemDownloadingPreview() { + val update = Update( + packageName = "com.example.app", + versionCode = 100, + versionName = "1.0.0", + displayName = "Example App", + iconURL = "", + changelog = "", + id = 1, + developerName = "Developer", + size = 5_000_000, + updatedOn = "May 2025", + hasValidCert = true, + offerType = 1, + fileList = emptyList(), + sharedLibs = emptyList() + ) + val download = Download.fromUpdate(update).copy( + status = DownloadStatus.DOWNLOADING, + progress = 45 + ) + AppUpdateItem(update = update, download = download) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/InstalledAppListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/InstalledAppListItem.kt new file mode 100644 index 000000000..1def9700b --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/app/InstalledAppListItem.kt @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable.app + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewWrapper +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.gplayapi.data.models.App +import com.aurora.store.R +import com.aurora.store.compose.composable.AuroraListItem +import com.aurora.store.compose.preview.AppPreviewProvider +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.util.CommonUtil + +/** + * List item for the installed-apps screen. Mirrors [LargeAppListItem] but the tertiary + * line shows installation-relevant info (APK size, last update, installer) rather than + * download / rating / price / ads which only matter on Play listings. + * + * Each tertiary segment is omitted if the corresponding value is missing, so the row + * gracefully collapses when only some metadata is available. + */ +@Composable +fun InstalledAppListItem( + modifier: Modifier = Modifier, + app: App, + sizeBytes: Long = 0L, + lastUpdateTime: Long = 0L, + installerLabel: String? = null, + onClick: () -> Unit = {} +) { + AuroraListItem( + modifier = modifier, + headline = app.displayName, + supporting = app.packageName, + tertiary = buildInstalledExtras(sizeBytes, lastUpdateTime, installerLabel), + headlineStyle = MaterialTheme.typography.bodyMedium, + onClick = onClick, + leading = { + AsyncImage( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))), + model = ImageRequest.Builder(LocalContext.current) + .data(app.iconArtwork.url) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + ) +} + +@Composable +private fun buildInstalledExtras( + sizeBytes: Long, + lastUpdateTime: Long, + installerLabel: String? +): String = buildList { + if (sizeBytes > 0) add(CommonUtil.addSiPrefix(sizeBytes)) + if (lastUpdateTime > 0) { + val relative = DateUtils.getRelativeTimeSpanString( + lastUpdateTime, + System.currentTimeMillis(), + DateUtils.DAY_IN_MILLIS + ) + add(stringResource(R.string.installed_updated_at, relative)) + } + if (!installerLabel.isNullOrBlank()) add(installerLabel) +}.joinToString(separator = " • ") + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun InstalledAppListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + InstalledAppListItem( + app = app, + sizeBytes = 54_300_000L, + lastUpdateTime = System.currentTimeMillis() - DateUtils.DAY_IN_MILLIS * 5, + installerLabel = "Aurora Store" + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt index fbb949c8d..1498fb652 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/app/LargeAppListItem.kt @@ -5,15 +5,9 @@ package com.aurora.store.compose.composable.app -import androidx.compose.foundation.clickable -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.requiredSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -21,7 +15,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -31,79 +24,49 @@ import coil3.request.crossfade import com.aurora.extensions.requiresGMS import com.aurora.gplayapi.data.models.App import com.aurora.store.R +import com.aurora.store.compose.composable.AuroraListItem import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.util.CommonUtil -/** - * Composable for displaying minimal app details in a vertical-scrollable list - * @param modifier The modifier to be applied to the composable - * @param app [App] to display - * @param onClick Callback when the composable is clicked - * @see AppListItem - */ @Composable fun LargeAppListItem(modifier: Modifier = Modifier, app: App, onClick: () -> Unit = {}) { - @Composable - fun buildExtras(app: App): List = mutableListOf().apply { - add(if (app.size > 0) CommonUtil.addSiPrefix(app.size) else app.downloadString) - add("${app.labeledRating}★") - - if (app.isFree) { - add(stringResource(R.string.details_free)) - } else { - add(stringResource(R.string.details_paid)) - } - - if (app.containsAds) { - add(stringResource(R.string.details_contains_ads)) - } else { - add(stringResource(R.string.details_no_ads)) - } - - if (app.requiresGMS()) { - add(stringResource(R.string.details_gsf_dependent)) - } - } - - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_small) - ) - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(app.iconArtwork.url) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .requiredSize(dimensionResource(R.dimen.icon_size_medium)) - .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) - ) - Column( - modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)) - ) { - Text( - text = app.displayName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text(text = app.developerName, style = MaterialTheme.typography.bodySmall) - Text( - text = buildExtras(app).joinToString(separator = " • "), - style = MaterialTheme.typography.bodySmall + AuroraListItem( + modifier = modifier, + headline = app.displayName, + supporting = app.developerName, + tertiary = buildAppExtras(app), + headlineStyle = MaterialTheme.typography.bodyMedium, + onClick = onClick, + leading = { + AsyncImage( + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))), + model = ImageRequest.Builder(LocalContext.current) + .data(app.iconArtwork.url) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop ) } - } + ) } +@Composable +private fun buildAppExtras(app: App): String = buildList { + add(if (app.size > 0) CommonUtil.addSiPrefix(app.size) else app.downloadString) + add("${app.labeledRating}★") + add(stringResource(if (app.isFree) R.string.details_free else R.string.details_paid)) + add( + stringResource( + if (app.containsAds) R.string.details_contains_ads else R.string.details_no_ads + ) + ) + if (app.requiresGMS()) add(stringResource(R.string.details_gsf_dependent)) +}.joinToString(separator = " • ") + @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable diff --git a/app/src/main/java/com/aurora/store/compose/composable/details/ExodusListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/details/ExodusListItem.kt index 69576ef9d..e49016dfe 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/details/ExodusListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/details/ExodusListItem.kt @@ -38,7 +38,7 @@ fun ExodusListItem(modifier: Modifier = Modifier, tracker: ExodusTracker) { onClick = { context.browse(tracker.url) }, enabled = tracker.url.isNotBlank() ) - .padding(dimensionResource(R.dimen.padding_small)) + .padding(dimensionResource(R.dimen.spacing_small)) ) { Text( text = tracker.name, diff --git a/app/src/main/java/com/aurora/store/compose/composable/details/RatingListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/details/RatingListItem.kt index 3a34b889f..202cb5adc 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/details/RatingListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/details/RatingListItem.kt @@ -34,9 +34,9 @@ fun RatingListItem(modifier: Modifier = Modifier, label: String, rating: Float) Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = dimensionResource(R.dimen.padding_small)), + .padding(horizontal = dimensionResource(R.dimen.spacing_small)), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) ) { Text( text = label, diff --git a/app/src/main/java/com/aurora/store/compose/composable/details/ReviewListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/details/ReviewListItem.kt index 9d6579c07..a1ffd0f7b 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/details/ReviewListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/details/ReviewListItem.kt @@ -45,8 +45,8 @@ fun ReviewListItem(modifier: Modifier = Modifier, review: Review) { modifier = modifier .fillMaxWidth() .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_small) + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) ) ) { AsyncImage( @@ -61,7 +61,7 @@ fun ReviewListItem(modifier: Modifier = Modifier, review: Review) { .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) ) Column( - modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)) + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.spacing_small)) ) { Text( text = review.userName, diff --git a/app/src/main/java/com/aurora/store/compose/composition/FlowExtensions.kt b/app/src/main/java/com/aurora/store/compose/composition/FlowExtensions.kt new file mode 100644 index 000000000..c69aff06a --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composition/FlowExtensions.kt @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composition + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow + +/** + * Collects a [Flow] as [State] starting from [initial], always triggering recomposition on + * every emission regardless of structural equality, by using [neverEqualPolicy]. + * + * Useful for non-state flows (e.g. [kotlinx.coroutines.flow.SharedFlow]) and for flows whose + * values have broken `equals` implementations that would otherwise be conflated upstream. + */ +@Composable +internal fun Flow.collectForced(initial: T): State { + val lifecycleOwner = LocalLifecycleOwner.current + val state: MutableState = remember(this) { mutableStateOf(initial, neverEqualPolicy()) } + LaunchedEffect(this, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + collect { state.value = it } + } + } + return state +} + +/** + * Observes a [LiveData] as [State], always triggering recomposition on every emission + * regardless of structural equality, by using [neverEqualPolicy]. + */ +@Suppress("UNCHECKED_CAST") +@Composable +internal fun LiveData.observeForced(): State { + val lifecycleOwner = LocalLifecycleOwner.current + val state: MutableState = remember { mutableStateOf(value, neverEqualPolicy()) } + DisposableEffect(this, lifecycleOwner) { + val observer = Observer { state.value = it } + observe(lifecycleOwner, observer) + onDispose { removeObserver(observer) } + } + return state +} diff --git a/app/src/main/java/com/aurora/store/compose/composition/LocalNetworkStatus.kt b/app/src/main/java/com/aurora/store/compose/composition/LocalNetworkStatus.kt new file mode 100644 index 000000000..46d30dc6d --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composition/LocalNetworkStatus.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composition + +import androidx.compose.runtime.compositionLocalOf +import com.aurora.store.data.model.NetworkStatus + +/** + * CompositionLocal carrying the current device network status. Provided once at the + * activity root from a single [com.aurora.store.data.providers.NetworkProvider] subscription, + * so any screen can read `LocalNetworkStatus.current` without injecting the provider + * or duplicating the flow collection. + * + * Uses [compositionLocalOf] (not static) so only readers recompose on change. + */ +val LocalNetworkStatus = compositionLocalOf { NetworkStatus.AVAILABLE } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt new file mode 100644 index 000000000..da381c775 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/navigation/Destination.kt @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.navigation + +import com.aurora.gplayapi.data.models.Category +import com.aurora.gplayapi.data.models.StreamCluster +import com.aurora.store.data.model.PermissionType +import com.aurora.store.data.room.update.Update + +/** + * All navigation actions available to composable screens. + * Screens emit one of these via a single `onNavigateTo: (Destination) -> Unit` callback. + */ +sealed class Destination { + data object Splash : Destination() + data class Main(val initialTab: Int) : Destination() + + data class AppDetails(val packageName: String) : Destination() + data class DevProfile(val devId: String) : Destination() + data class AppUpdate(val update: Update) : Destination() + + data object Search : Destination() + data object Downloads : Destination() + + data class StreamBrowse(val cluster: StreamCluster) : Destination() + data class ExpandedStreamBrowse(val title: String, val browseUrl: String) : Destination() + data class CategoryBrowse(val category: Category) : Destination() + data class PermissionRationale(val permissions: Set) : Destination() + + data object Accounts : Destination() + data object GoogleLogin : Destination() + data object About : Destination() + data object Favourite : Destination() + data object Spoof : Destination() + data object Installed : Destination() + data object Blacklist : Destination() + + data object Settings : Destination() + data object InstallationPreference : Destination() + data object Installer : Destination() + data object NetworkPreference : Destination() + data object Dispenser : Destination() + data object UIPreference : Destination() + data object UpdatesPreference : Destination() + data object SourceFilters : Destination() +} diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index 96ff65379..0846dc0cf 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -7,16 +7,23 @@ package com.aurora.store.compose.navigation import android.content.Intent -import androidx.activity.compose.LocalActivity +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation.NavDeepLinkBuilder import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.metadata import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay @@ -24,11 +31,13 @@ import com.aurora.Constants.PACKAGE_NAME_GMS import com.aurora.extensions.toast import com.aurora.store.AuroraApp import com.aurora.store.ComposeActivity -import com.aurora.store.MainActivity import com.aurora.store.R import com.aurora.store.compose.ui.about.AboutScreen import com.aurora.store.compose.ui.accounts.AccountsScreen +import com.aurora.store.compose.ui.accounts.GoogleLoginScreen import com.aurora.store.compose.ui.blacklist.BlacklistScreen +import com.aurora.store.compose.ui.commons.CategoryBrowseScreen +import com.aurora.store.compose.ui.commons.ExpandedStreamBrowseScreen import com.aurora.store.compose.ui.commons.PermissionRationaleScreen import com.aurora.store.compose.ui.commons.StreamBrowseScreen import com.aurora.store.compose.ui.details.AppDetailsScreen @@ -37,9 +46,17 @@ import com.aurora.store.compose.ui.dispenser.DispenserScreen import com.aurora.store.compose.ui.downloads.DownloadsScreen import com.aurora.store.compose.ui.favourite.FavouriteScreen import com.aurora.store.compose.ui.installed.InstalledScreen +import com.aurora.store.compose.ui.main.MainScreen import com.aurora.store.compose.ui.onboarding.OnboardingScreen +import com.aurora.store.compose.ui.preferences.SettingsScreen +import com.aurora.store.compose.ui.preferences.UIPreferenceScreen +import com.aurora.store.compose.ui.preferences.installation.InstallationPreferenceScreen import com.aurora.store.compose.ui.preferences.installation.InstallerScreen +import com.aurora.store.compose.ui.preferences.network.NetworkPreferenceScreen +import com.aurora.store.compose.ui.preferences.updates.SourceFiltersScreen +import com.aurora.store.compose.ui.preferences.updates.UpdatesPreferenceScreen import com.aurora.store.compose.ui.search.SearchScreen +import com.aurora.store.compose.ui.splash.SplashScreen import com.aurora.store.compose.ui.spoof.SpoofScreen import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.model.AccountType @@ -54,23 +71,6 @@ import com.aurora.store.util.Preferences @Composable fun NavDisplay(startDestination: NavKey) { val backstack = rememberNavBackStack(startDestination) - - // TODO: Rework when migrating splash fragment to compose - val splashIntent = NavDeepLinkBuilder(LocalContext.current) - .setGraph(R.navigation.mobile_navigation) - .setDestination(R.id.splashFragment) - .setComponentName(MainActivity::class.java) - .createTaskStackBuilder() - .intents - .first() - .apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) } - - // TODO: Drop this logic once everything is in compose - val activity = LocalActivity.current - fun onNavigateUp() { - if (backstack.size == 1) activity?.finish() else backstack.removeLastOrNull() - } - val context = LocalContext.current fun isMicroGAuthInvalidated(): Boolean = @@ -101,114 +101,171 @@ fun NavDisplay(startDestination: NavKey) { } } + fun navigate(destination: Destination) { + when (destination) { + Destination.Splash -> { + // Clear the backstack when navigating to Splash to prevent going back to the previous screen when the user is sent back to the splash screen (e.g. after logout). + backstack.clear() + backstack.add(Screen.Splash) + } + + is Destination.Main -> { + // Clear the backstack when navigating to Main to prevent going back to the splash screen or other screens. + backstack.clear() + backstack.add(Screen.Main(destination.initialTab)) + } + + is Destination.ExpandedStreamBrowse -> backstack.add( + Screen.ExpandedStreamBrowse(destination.title, destination.browseUrl) + ) + + is Destination.CategoryBrowse -> backstack.add( + Screen.CategoryBrowse(destination.category.title, destination.category.browseUrl) + ) + + is Destination.PermissionRationale -> backstack.add( + Screen.PermissionRationale(destination.permissions) + ) + + is Destination.AppDetails -> backstack.add(Screen.AppDetails(destination.packageName)) + is Destination.DevProfile -> backstack.add(Screen.DevProfile(destination.devId)) + is Destination.AppUpdate -> Unit + is Destination.StreamBrowse -> backstack.add(Screen.StreamBrowse(destination.cluster)) + + Destination.Search -> backstack.add(Screen.Search) + Destination.Downloads -> backstack.add(Screen.Downloads) + Destination.Accounts -> backstack.add(Screen.Accounts) + Destination.GoogleLogin -> backstack.add(Screen.GoogleLogin) + Destination.About -> backstack.add(Screen.About) + Destination.Favourite -> backstack.add(Screen.Favourite) + Destination.Spoof -> backstack.add(Screen.Spoof) + Destination.Installed -> backstack.add(Screen.Installed) + Destination.Blacklist -> backstack.add(Screen.Blacklist) + Destination.Settings -> backstack.add(Screen.Settings) + Destination.InstallationPreference -> backstack.add(Screen.InstallationPreference) + Destination.Installer -> backstack.add(Screen.Installer) + Destination.NetworkPreference -> backstack.add(Screen.NetworkPreference) + Destination.Dispenser -> backstack.add(Screen.Dispenser) + Destination.UIPreference -> backstack.add(Screen.UIPreference) + Destination.UpdatesPreference -> backstack.add(Screen.UpdatesPreference) + Destination.SourceFilters -> backstack.add(Screen.SourceFilters) + } + } + NavDisplay( + onBack = { backstack.removeLastOrNull() }, backStack = backstack, entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator() ), + transitionSpec = { + slideInHorizontally( + animationSpec = spring(dampingRatio = 0.8f, stiffness = 380f), + initialOffsetX = { it } + ) togetherWith slideOutHorizontally(targetOffsetX = { -it }) + }, + popTransitionSpec = { + slideInHorizontally( + animationSpec = spring(dampingRatio = 0.8f, stiffness = 380f), + initialOffsetX = { -it } + ) togetherWith slideOutHorizontally(targetOffsetX = { it }) + }, + predictivePopTransitionSpec = { + slideInHorizontally( + animationSpec = spring(dampingRatio = 0.8f, stiffness = 380f), + initialOffsetX = { -it } + ) togetherWith slideOutHorizontally(targetOffsetX = { it }) + }, entryProvider = entryProvider { - entry { - BlacklistScreen(onNavigateUp = ::onNavigateUp) - } - - entry { - SearchScreen(onNavigateUp = ::onNavigateUp) + entry { screen -> + MainScreen( + initialTab = screen.initialTab, + onNavigateTo = ::navigate + ) } entry { screen -> AppDetailsScreen( packageName = screen.packageName, - onNavigateUp = ::onNavigateUp, - onNavigateToAppDetails = { packageName -> - backstack.add(Screen.AppDetails(packageName)) - } + onNavigateTo = ::navigate ) } entry { screen -> DevProfileScreen( developerId = screen.developerId, - onNavigateUp = ::onNavigateUp, - onNavigateToAppDetails = { packageName -> - backstack.add(Screen.AppDetails(packageName)) - } + onNavigateTo = ::navigate ) } entry { screen -> PermissionRationaleScreen( - onNavigateUp = ::onNavigateUp, requiredPermissions = screen.requiredPermissions ) } - entry { - DownloadsScreen( - onNavigateUp = ::onNavigateUp, - onNavigateToAppDetails = { packageName -> - backstack.add(Screen.AppDetails(packageName)) - } - ) - } - - entry { - AccountsScreen( - onNavigateUp = ::onNavigateUp, - onNavigateToSplash = { activity?.startActivity(splashIntent) } - ) - } - - entry { - AboutScreen(onNavigateUp = ::onNavigateUp) - } - - entry { - FavouriteScreen( - onNavigateUp = ::onNavigateUp, - onNavigateToAppDetails = { packageName -> - backstack.add(Screen.AppDetails(packageName)) - } - ) - } - - entry { - OnboardingScreen() - } - - entry { - SpoofScreen( - onNavigateUp = ::onNavigateUp, - onNavigateToSplash = { activity?.startActivity(splashIntent) } - ) - } - - entry { - DispenserScreen(onNavigateUp = ::onNavigateUp) - } - - entry { - InstallerScreen(onNavigateUp = ::onNavigateUp) - } - - entry { - InstalledScreen( - onNavigateUp = ::onNavigateUp, - onNavigateToAppDetails = { packageName -> - backstack.add(Screen.AppDetails(packageName)) - } - ) - } - entry { screen -> StreamBrowseScreen( streamCluster = screen.streamCluster, - onNavigateUp = ::onNavigateUp, - onNavigateToAppDetails = { packageName -> - backstack.add(Screen.AppDetails(packageName)) - } + onNavigateTo = ::navigate ) } + + entry { screen -> + ExpandedStreamBrowseScreen( + browseUrl = screen.browseUrl, + defaultTitle = screen.title, + onNavigateTo = ::navigate + ) + } + + entry { screen -> + CategoryBrowseScreen( + title = screen.title, + browseUrl = screen.browseUrl, + onNavigateTo = ::navigate + ) + } + + entry { + InstallationPreferenceScreen(onNavigateTo = ::navigate) + } + + entry( + metadata = metadata { + put(NavDisplay.TransitionKey) { + fadeIn() togetherWith + ExitTransition.KeepUntilTransitionsFinished + } + put(NavDisplay.PopTransitionKey) { + EnterTransition.None togetherWith + slideOutVertically(targetOffsetY = { it }) + } + put(NavDisplay.PredictivePopTransitionKey) { + EnterTransition.None togetherWith + slideOutVertically(targetOffsetY = { it }) + } + } + ) { SearchScreen() } + + entry { SplashScreen(onNavigateTo = ::navigate) } + entry { OnboardingScreen() } + entry { BlacklistScreen() } + entry { DownloadsScreen(onNavigateTo = ::navigate) } + entry { AccountsScreen(onNavigateTo = ::navigate) } + entry { GoogleLoginScreen(onNavigateTo = ::navigate) } + entry { AboutScreen() } + entry { FavouriteScreen(onNavigateTo = ::navigate) } + entry { SpoofScreen(onNavigateTo = ::navigate) } + entry { DispenserScreen() } + entry { InstallerScreen() } + entry { InstalledScreen(onNavigateTo = ::navigate) } + entry { SettingsScreen(onNavigateTo = ::navigate) } + entry { NetworkPreferenceScreen(onNavigateTo = ::navigate) } + entry { UIPreferenceScreen() } + entry { UpdatesPreferenceScreen(onNavigateTo = ::navigate) } + entry { SourceFiltersScreen() } } ) } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt index be2989737..f8b0b0870 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt @@ -44,6 +44,9 @@ sealed class Screen : NavKey, Parcelable { @Serializable data object Accounts : Screen() + @Serializable + data object GoogleLogin : Screen() + @Serializable data object About : Screen() @@ -67,4 +70,34 @@ sealed class Screen : NavKey, Parcelable { @Serializable data class StreamBrowse(val streamCluster: StreamCluster) : Screen() + + @Serializable + data class ExpandedStreamBrowse(val title: String, val browseUrl: String) : Screen() + + @Serializable + data class CategoryBrowse(val title: String, val browseUrl: String) : Screen() + + @Serializable + data object Settings : Screen() + + @Serializable + data object InstallationPreference : Screen() + + @Serializable + data object NetworkPreference : Screen() + + @Serializable + data object UIPreference : Screen() + + @Serializable + data object UpdatesPreference : Screen() + + @Serializable + data object SourceFilters : Screen() + + @Serializable + data object Splash : Screen() + + @Serializable + data class Main(val initialTab: Int = 0) : Screen() } diff --git a/app/src/main/java/com/aurora/store/compose/theme/Color.kt b/app/src/main/java/com/aurora/store/compose/theme/Color.kt index 8e2d709a6..8818eebb2 100644 --- a/app/src/main/java/com/aurora/store/compose/theme/Color.kt +++ b/app/src/main/java/com/aurora/store/compose/theme/Color.kt @@ -9,3 +9,6 @@ import androidx.compose.ui.graphics.Color val warningColor = Color(0xFFFF7600) // Amber val successColor = Color(0xFF1B8738) // Green + +val colorGreen = Color(0xFF4CAF50) +val colorRed = Color(0xFFF44336) diff --git a/app/src/main/java/com/aurora/store/compose/theme/Theme.kt b/app/src/main/java/com/aurora/store/compose/theme/Theme.kt index 3f6365fda..1b5d8cbb7 100644 --- a/app/src/main/java/com/aurora/store/compose/theme/Theme.kt +++ b/app/src/main/java/com/aurora/store/compose/theme/Theme.kt @@ -6,6 +6,7 @@ package com.aurora.store.compose.theme +import android.content.SharedPreferences import android.os.Build import androidx.activity.compose.LocalActivity import androidx.compose.foundation.isSystemInDarkTheme @@ -16,7 +17,12 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.colorResource @@ -31,7 +37,19 @@ import com.aurora.store.util.Preferences fun AuroraTheme(content: @Composable () -> Unit) { val context = LocalContext.current - val themeStyle = Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE) + var themeStyle by remember { + mutableIntStateOf(Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE)) + } + DisposableEffect(Unit) { + val prefs = Preferences.getPrefs(context) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == Preferences.PREFERENCE_THEME_STYLE) { + themeStyle = Preferences.getInteger(context, Preferences.PREFERENCE_THEME_STYLE) + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + } val isDynamicColorSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val lightScheme = if (isDynamicColorSupported) { @@ -69,11 +87,9 @@ fun AuroraTheme(content: @Composable () -> Unit) { val currentActivity = activity ?: return@SideEffect val window = currentActivity.window - // Transparent system bars window.statusBarColor = android.graphics.Color.TRANSPARENT window.navigationBarColor = android.graphics.Color.TRANSPARENT - // Control icon colors explicitly WindowCompat .getInsetsController(window, view) .apply { diff --git a/app/src/main/java/com/aurora/store/compose/ui/about/AboutScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/about/AboutScreen.kt index 79936b56b..255d4cfbf 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/about/AboutScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/about/AboutScreen.kt @@ -46,7 +46,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.model.Link @Composable -fun AboutScreen(onNavigateUp: () -> Unit) { +fun AboutScreen() { var shouldShowAboutDialog by rememberSaveable { mutableStateOf(false) } if (shouldShowAboutDialog) { @@ -54,13 +54,12 @@ fun AboutScreen(onNavigateUp: () -> Unit) { } ScreenContent( - onNavigateUp = onNavigateUp, onAboutAurora = { shouldShowAboutDialog = true } ) } @Composable -private fun ScreenContent(onNavigateUp: () -> Unit = {}, onAboutAurora: () -> Unit = {}) { +private fun ScreenContent(onAboutAurora: () -> Unit = {}) { val context = LocalContext.current val linkURLS = stringArrayResource(R.array.link_urls) @@ -94,8 +93,7 @@ private fun ScreenContent(onNavigateUp: () -> Unit = {}, onAboutAurora: () -> Un Scaffold( topBar = { TopAppBar( - title = stringResource(R.string.title_about), - onNavigateUp = onNavigateUp + title = stringResource(R.string.title_about) ) } ) { paddingValues -> @@ -103,7 +101,7 @@ private fun ScreenContent(onNavigateUp: () -> Unit = {}, onAboutAurora: () -> Un modifier = Modifier .fillMaxSize() .padding(paddingValues), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) ) { stickyHeader { Surface(modifier = Modifier.fillMaxWidth()) { @@ -130,9 +128,9 @@ private fun BrandHeader() { Column( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(R.dimen.margin_medium)), + .padding(dimensionResource(R.dimen.spacing_medium)), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt index b8bb41717..bbd9a6af6 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/AccountsScreen.kt @@ -48,19 +48,18 @@ import com.aurora.Constants.URL_TOS import com.aurora.extensions.browse import com.aurora.store.R import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.providers.AccountProvider import com.aurora.store.viewmodel.accounts.AccountsViewModel @Composable fun AccountsScreen( - onNavigateUp: () -> Unit, - onNavigateToSplash: () -> Unit, + onNavigateTo: (Destination) -> Unit, viewModel: AccountsViewModel = hiltViewModel() ) { val userProfile = viewModel.authProvider.authData?.userProfile val avatar = when { - viewModel.authProvider.isAnonymous -> R.mipmap.ic_launcher userProfile != null -> userProfile.artwork.url else -> R.mipmap.ic_launcher } @@ -70,7 +69,6 @@ fun AccountsScreen( else -> stringResource(R.string.account_anonymous) } val email = when { - viewModel.authProvider.isAnonymous -> stringResource(R.string.account_anonymous_email) userProfile != null -> userProfile.email else -> stringResource(R.string.account_anonymous_email) } @@ -83,14 +81,13 @@ fun AccountsScreen( onConfirm = { shouldShowLogoutDialog = false AccountProvider.logout(context) - onNavigateToSplash() + onNavigateTo(Destination.Splash) }, onDismiss = { shouldShowLogoutDialog = false } ) } ScreenContent( - onNavigateUp = onNavigateUp, avatar = avatar, name = name, email = email, @@ -100,17 +97,15 @@ fun AccountsScreen( @Composable private fun ScreenContent( - avatar: Any = R.mipmap.ic_launcher, + avatar: Any = R.drawable.ic_account, name: String = stringResource(R.string.account_anonymous), email: String = stringResource(R.string.account_anonymous_email), - onNavigateUp: () -> Unit = {}, onLogout: () -> Unit = {} ) { Scaffold( topBar = { TopAppBar( - title = stringResource(R.string.title_account_manager), - onNavigateUp = onNavigateUp + title = stringResource(R.string.title_account_manager) ) } ) { paddingValues -> @@ -118,7 +113,7 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)), + .padding(vertical = dimensionResource(R.dimen.spacing_small)), verticalArrangement = Arrangement.SpaceBetween ) { AssistHeader() @@ -126,7 +121,7 @@ private fun ScreenContent( Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) @@ -158,7 +153,9 @@ private fun ScreenContent( .fillMaxWidth() .padding(bottom = dimensionResource(R.dimen.height_bottom_adj)), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { Text( text = stringResource(R.string.account_logout), @@ -171,7 +168,7 @@ private fun ScreenContent( painter = painterResource(R.drawable.ic_logout), contentDescription = null ) - Spacer(modifier = Modifier.width(dimensionResource(R.dimen.padding_xsmall))) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.spacing_xsmall))) Text( text = stringResource(R.string.action_logout) ) @@ -193,8 +190,8 @@ private fun AssistHeader() { LazyRow( modifier = Modifier .fillMaxWidth() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)) + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { items(items = links.keys.toList(), key = { item -> item }) { label -> AssistChip( diff --git a/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt new file mode 100644 index 000000000..fb44474c6 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/accounts/GoogleLoginScreen.kt @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.accounts + +import android.os.Build +import android.webkit.CookieManager +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.aurora.gplayapi.helpers.AuthHelper +import com.aurora.store.AuroraApp +import com.aurora.store.R +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.data.event.AuthEvent +import com.aurora.store.data.model.AccountType +import com.aurora.store.data.providers.AccountProvider +import com.aurora.store.util.AC2DMUtil +import com.aurora.store.viewmodel.auth.AuthViewModel + +private const val EMBEDDED_SETUP_URL = "https://accounts.google.com/EmbeddedSetup" +private const val AUTH_TOKEN = "oauth_token" +private const val JS_PROFILE_EMAIL = + "(function() { return document.getElementById('profileIdentifier').innerHTML; })();" + +@Composable +fun GoogleLoginScreen( + onNavigateTo: (Destination) -> Unit, + viewModel: AuthViewModel = hiltViewModel() +) { + val context = LocalContext.current + var progress by remember { mutableFloatStateOf(0f) } + var isIndeterminate by remember { mutableStateOf(true) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + AuroraApp.events.authEvent.collect { event -> + if (event is AuthEvent.GoogleLogin) { + if (event.success) { + AccountProvider.login( + context, + event.email, + event.token, + AuthHelper.Token.AAS, + AccountType.GOOGLE + ) + } else { + Toast.makeText(context, R.string.toast_aas_token_failed, Toast.LENGTH_LONG) + .show() + } + onNavigateTo(Destination.Splash) + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + if (isLoading) { + if (isIndeterminate) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth() + ) + } + } + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val cookieManager = CookieManager.getInstance() + WebView(ctx).apply { + cookieManager.removeAllCookies(null) + cookieManager.acceptThirdPartyCookies(this) + cookieManager.setAcceptThirdPartyCookies(this, true) + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + super.onProgressChanged(view, newProgress) + if (newProgress != 0) { + isIndeterminate = false + progress = newProgress / 100f + isLoading = newProgress < 100 + } else { + isIndeterminate = true + isLoading = true + } + } + } + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + val cookies = CookieManager.getInstance().getCookie(url) + if (cookies != null) { + val cookieMap = AC2DMUtil.parseCookieString(cookies) + if (cookieMap.isNotEmpty() && cookieMap[AUTH_TOKEN] != null) { + val oauthToken = cookieMap[AUTH_TOKEN] + view.evaluateJavascript(JS_PROFILE_EMAIL) { result -> + val email = result.replace("\"", "") + viewModel.buildAuthData(view.context, email, oauthToken) + } + } + } + } + } + + settings.apply { + allowContentAccess = true + domStorageEnabled = true + javaScriptEnabled = true + cacheMode = WebSettings.LOAD_DEFAULT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + safeBrowsingEnabled = false + } + } + + loadUrl(EMBEDDED_SETUP_URL) + } + } + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/AppsGamesScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/AppsGamesScreen.kt new file mode 100644 index 000000000..22a248f2c --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/AppsGamesScreen.kt @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.apps + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.aurora.gplayapi.helpers.contracts.StreamContract +import com.aurora.gplayapi.helpers.contracts.TopChartsContract +import com.aurora.store.R +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.util.Preferences +import com.aurora.store.viewmodel.category.CategoryViewModel +import com.aurora.store.viewmodel.homestream.StreamViewModel +import com.aurora.store.viewmodel.topchart.TopChartViewModel +import kotlinx.coroutines.launch + +internal fun category(pageType: Int): StreamContract.Category = + if (pageType == 1) StreamContract.Category.GAME else StreamContract.Category.APPLICATION + +private enum class AppsTab(@StringRes val titleRes: Int) { + FOR_YOU(R.string.tab_for_you), + TOP_CHARTS(R.string.tab_top_charts), + CATEGORIES(R.string.tab_categories) +} + +@Composable +fun AppsGamesScreen( + pageType: Int, + streamViewModel: StreamViewModel = hiltViewModel(key = "stream_$pageType"), + topChartViewModel: TopChartViewModel = hiltViewModel(key = "topChart_$pageType"), + categoryViewModel: CategoryViewModel = hiltViewModel(key = "category_$pageType"), + onNavigateTo: (Destination) -> Unit = {} +) { + val context = LocalContext.current + + val chartType = if (pageType == 1) { + TopChartsContract.Type.GAME + } else { + TopChartsContract.Type.APPLICATION + } + + LaunchedEffect(Unit) { + topChartViewModel.getStreamCluster(chartType, TopChartsContract.Chart.TOP_SELLING_FREE) + } + + val isForYouEnabled = Preferences.getBoolean(context, Preferences.PREFERENCE_FOR_YOU) + val tabs = buildList { + if (isForYouEnabled) add(AppsTab.FOR_YOU) + add(AppsTab.TOP_CHARTS) + add(AppsTab.CATEGORIES) + } + val pagerState = rememberPagerState { tabs.size } + val coroutineScope = rememberCoroutineScope() + + Column(modifier = Modifier.fillMaxSize()) { + PrimaryTabRow( + modifier = Modifier.fillMaxWidth(), + selectedTabIndex = pagerState.currentPage + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(stringResource(tab.titleRes)) } + ) + } + } + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + verticalAlignment = Alignment.Top, + userScrollEnabled = false + ) { page -> + when (tabs[page]) { + AppsTab.FOR_YOU -> ForYouContent( + pageType = pageType, + viewModel = streamViewModel, + onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, + onHeaderClick = { onNavigateTo(Destination.StreamBrowse(it)) }, + onClusterScrolled = { cluster -> + streamViewModel.observeCluster(category(pageType), cluster) + }, + onScrolledToEnd = { + streamViewModel.observe(category(pageType), StreamContract.Type.HOME) + } + ) + + AppsTab.TOP_CHARTS -> TopChartsContent( + pageType = pageType, + viewModel = topChartViewModel, + onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) } + ) + + AppsTab.CATEGORIES -> CategoriesContent( + pageType = pageType, + viewModel = categoryViewModel, + onCategoryClick = { onNavigateTo(Destination.CategoryBrowse(it)) } + ) + } + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt new file mode 100644 index 000000000..c07ae64a5 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/CategoriesPage.kt @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.apps + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.gplayapi.data.models.Category +import com.aurora.store.CategoryStash +import com.aurora.store.compose.composable.CategoryItem +import com.aurora.store.compose.composable.ShimmerCategoryRow +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.data.model.ViewState +import com.aurora.store.viewmodel.category.CategoryViewModel + +@Composable +internal fun CategoriesContent( + pageType: Int, + viewModel: CategoryViewModel, + onCategoryClick: (Category) -> Unit +) { + val categoryType = if (pageType == 1) Category.Type.GAME else Category.Type.APPLICATION + val state by viewModel.liveData.observeAsState() + + LaunchedEffect(categoryType) { + viewModel.getCategoryList(categoryType) + } + + @Suppress("UNCHECKED_CAST") + val categories = (state as? ViewState.Success<*>)?.data as? CategoryStash + val list = categories?.get(categoryType) + + CategoriesBody(list = list, onCategoryClick = onCategoryClick) +} + +@Composable +private fun CategoriesBody(list: List?, onCategoryClick: (Category) -> Unit = {}) { + if (list.isNullOrEmpty()) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(10) { ShimmerCategoryRow() } + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(count = list.size, key = { list[it].title }) { index -> + CategoryItem( + category = list[index], + onClick = { onCategoryClick(list[index]) } + ) + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun CategoriesBodyLoadingPreview() { + CategoriesBody(list = null) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun CategoriesBodyLoadedPreview() { + val categories = listOf( + "Art & Design", + "Auto & Vehicles", + "Beauty", + "Books & Reference", + "Business", + "Comics", + "Communication", + "Dating", + "Education", + "Entertainment" + ).map { Category(title = it, imageUrl = "") } + CategoriesBody(list = categories) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt new file mode 100644 index 000000000..24daf6dc1 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/ForYouPage.kt @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.apps + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.StreamCluster +import com.aurora.gplayapi.helpers.contracts.StreamContract +import com.aurora.store.HomeStash +import com.aurora.store.compose.composable.StreamCarousel +import com.aurora.store.compose.composition.observeForced +import com.aurora.store.data.model.ViewState +import com.aurora.store.viewmodel.homestream.StreamViewModel + +@Composable +internal fun ForYouContent( + pageType: Int, + viewModel: StreamViewModel, + onAppClick: (App) -> Unit, + onHeaderClick: (StreamCluster) -> Unit, + onClusterScrolled: (StreamCluster) -> Unit, + onScrolledToEnd: () -> Unit +) { + val category = category(pageType) + val state by viewModel.liveData.observeForced() + + LaunchedEffect(category) { + viewModel.getStreamBundle(category, StreamContract.Type.HOME) + } + + @Suppress("UNCHECKED_CAST") + val streamBundle = (state as? ViewState.Success<*>)?.data as? HomeStash + StreamCarousel( + modifier = Modifier.fillMaxSize(), + streamBundle = streamBundle?.get(category), + onHeaderClick = onHeaderClick, + onAppClick = onAppClick, + onClusterScrolled = onClusterScrolled, + onScrolledToEnd = onScrolledToEnd + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/apps/TopChartsPage.kt b/app/src/main/java/com/aurora/store/compose/ui/apps/TopChartsPage.kt new file mode 100644 index 000000000..95d23d4a1 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/apps/TopChartsPage.kt @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.apps + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.SecondaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.StreamCluster +import com.aurora.gplayapi.helpers.contracts.TopChartsContract +import com.aurora.store.R +import com.aurora.store.compose.composable.Placeholder +import com.aurora.store.compose.composable.ShimmerAppRow +import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.composition.collectForced +import com.aurora.store.compose.preview.AppPreviewProvider +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.data.model.ViewState +import com.aurora.store.data.model.ViewState.Loading.getDataAs +import com.aurora.store.viewmodel.topchart.TopChartViewModel + +private const val LOAD_MORE_THRESHOLD = 2 + +@Composable +internal fun TopChartsContent( + pageType: Int, + viewModel: TopChartViewModel, + onAppClick: (App) -> Unit +) { + val charts = listOf( + TopChartsContract.Chart.TOP_SELLING_FREE, + TopChartsContract.Chart.TOP_GROSSING, + TopChartsContract.Chart.MOVERS_SHAKERS, + TopChartsContract.Chart.TOP_SELLING_PAID + ) + val chartTitles = listOf( + R.string.tab_top_free, + R.string.tab_top_grossing, + R.string.tab_trending, + R.string.tab_top_paid + ) + val chartType = + if (pageType == 1) TopChartsContract.Type.GAME else TopChartsContract.Type.APPLICATION + var selectedIndex by rememberSaveable { mutableIntStateOf(0) } + val selectedChart = charts[selectedIndex] + val state by viewModel.state.collectForced(ViewState.Loading) + val cluster = state.getDataAs() + val listState = rememberLazyListState() + + LaunchedEffect(selectedIndex) { + listState.scrollToItem(0) + viewModel.getStreamCluster(chartType, selectedChart) + } + + val reachedEnd by remember { + derivedStateOf { + val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + val total = listState.layoutInfo.totalItemsCount + last >= total - LOAD_MORE_THRESHOLD + } + } + LaunchedEffect(reachedEnd) { + if (reachedEnd && cluster?.hasNext() == true) { + viewModel.nextCluster(chartType, selectedChart) + } + } + + TopChartsBody( + selectedIndex = selectedIndex, + chartTitles = chartTitles, + state = state, + cluster = cluster, + listState = listState, + onTabSelected = { selectedIndex = it }, + onRetry = { viewModel.getStreamCluster(chartType, selectedChart) }, + onAppClick = onAppClick + ) +} + +@Composable +private fun TopChartsBody( + selectedIndex: Int, + chartTitles: List, + state: ViewState, + cluster: StreamCluster?, + listState: LazyListState = rememberLazyListState(), + onTabSelected: (Int) -> Unit = {}, + onRetry: () -> Unit = {}, + onAppClick: (App) -> Unit = {} +) { + Column( + modifier = Modifier.fillMaxSize() + ) { + SecondaryScrollableTabRow( + selectedTabIndex = selectedIndex, + edgePadding = dimensionResource(R.dimen.spacing_small) + ) { + chartTitles.forEachIndexed { index, titleRes -> + Tab( + selected = selectedIndex == index, + onClick = { onTabSelected(index) }, + text = { Text(stringResource(titleRes)) } + ) + } + } + + when { + state is ViewState.Error -> Placeholder( + modifier = Modifier.weight(1f), + painter = painterResource(R.drawable.ic_apps), + message = stringResource(R.string.no_apps_available), + actionLabel = stringResource(R.string.action_retry), + onAction = onRetry + ) + + cluster != null && cluster.clusterAppList.isEmpty() -> Placeholder( + modifier = Modifier.weight(1f), + painter = painterResource(R.drawable.ic_apps), + message = stringResource(R.string.no_apps_available) + ) + + cluster != null -> { + val apps = cluster.clusterAppList + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + state = listState, + contentPadding = PaddingValues( + vertical = dimensionResource(R.dimen.spacing_small) + ), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_xsmall) + ) + ) { + items(count = apps.size, key = { apps[it].id }) { index -> + LargeAppListItem( + app = apps[index], + onClick = { onAppClick(apps[index]) } + ) + } + if (cluster.hasNext()) { + item(key = "progress") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.spacing_large)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + } + } + + else -> LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(vertical = dimensionResource(R.dimen.spacing_small)), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_xsmall) + ) + ) { + items(8) { ShimmerAppRow() } + } + } + } +} + +private val previewChartTitles = listOf( + R.string.tab_top_free, + R.string.tab_top_grossing, + R.string.tab_trending, + R.string.tab_top_paid +) + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun TopChartsBodyLoadingPreview() { + TopChartsBody( + selectedIndex = 0, + chartTitles = previewChartTitles, + state = ViewState.Loading, + cluster = null + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun TopChartsBodyLoadedPreview() { + val app = AppPreviewProvider().values.first() + val apps = List(5) { i -> app.copy(id = i + 1, packageName = "com.preview.app$i") } + val cluster = StreamCluster(clusterAppList = apps) + TopChartsBody( + selectedIndex = 0, + chartTitles = previewChartTitles, + state = ViewState.Success(cluster), + cluster = cluster + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun TopChartsBodyEmptyPreview() { + val cluster = StreamCluster(clusterAppList = emptyList()) + TopChartsBody( + selectedIndex = 1, + chartTitles = previewChartTitles, + state = ViewState.Success(cluster), + cluster = cluster + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun TopChartsBodyErrorPreview() { + TopChartsBody( + selectedIndex = 0, + chartTitles = previewChartTitles, + state = ViewState.Error("Network error"), + cluster = null + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt index f82cb0e0b..e02503127 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/blacklist/BlacklistScreen.kt @@ -7,6 +7,8 @@ package com.aurora.store.compose.ui.blacklist import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable @@ -36,8 +38,10 @@ import androidx.compose.material3.rememberSearchBarState 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.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,7 +55,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.Constants @@ -64,6 +67,8 @@ import com.aurora.store.compose.composable.TextDividerComposable import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.ui.blacklist.menu.BlacklistMenu import com.aurora.store.compose.ui.blacklist.menu.MenuItem +import com.aurora.store.compose.ui.commons.SortFilterSheet +import com.aurora.store.compose.ui.commons.SortFilterState import com.aurora.store.data.model.BlacklistAppItem import com.aurora.store.viewmodel.blacklist.BlacklistViewModel import java.util.Calendar @@ -72,13 +77,16 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @Composable -fun BlacklistScreen(onNavigateUp: () -> Unit, viewModel: BlacklistViewModel = hiltViewModel()) { +fun BlacklistScreen(viewModel: BlacklistViewModel = hiltViewModel()) { val context = LocalContext.current val packages by viewModel.filteredPackages.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val installers by viewModel.installers.collectAsStateWithLifecycle() ScreenContent( packages = packages, - onNavigateUp = onNavigateUp, + sortFilterState = state, + installers = installers, isPackageBlacklisted = { pkgName -> pkgName in viewModel.blacklist }, onBlacklistImport = { uri -> viewModel.importBlacklist(uri) @@ -92,14 +100,16 @@ fun BlacklistScreen(onNavigateUp: () -> Unit, viewModel: BlacklistViewModel = hi onBlacklistAll = { viewModel.blacklistAll() }, onWhitelist = { packageName -> viewModel.whitelist(packageName) }, onWhitelistAll = { viewModel.whitelistAll() }, - onSearch = { query -> viewModel.search(query) } + onSearch = { query -> viewModel.search(query) }, + onSortFilterStateChange = viewModel::updateState ) } @Composable private fun ScreenContent( packages: List? = null, - onNavigateUp: () -> Unit = {}, + sortFilterState: SortFilterState = SortFilterState(), + installers: Map = emptyMap(), isPackageBlacklisted: (packageName: String) -> Boolean = { false }, onBlacklistImport: (uri: Uri) -> Unit = {}, onBlacklistExport: (uri: Uri) -> Unit = {}, @@ -107,13 +117,16 @@ private fun ScreenContent( onBlacklistAll: () -> Unit = {}, onWhitelist: (packageName: String) -> Unit = {}, onWhitelistAll: () -> Unit = {}, - onSearch: (query: String) -> Unit = {} + onSearch: (query: String) -> Unit = {}, + onSortFilterStateChange: (SortFilterState) -> Unit = {} ) { + val activity = LocalActivity.current as? ComponentActivity val context = LocalContext.current val textFieldState = rememberTextFieldState() val searchBarState = rememberSearchBarState() val focusRequester = remember { FocusRequester() } val coroutineScope = rememberCoroutineScope() + var sheetVisible by remember { mutableStateOf(false) } val docImportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), @@ -224,14 +237,22 @@ private fun ScreenContent( state = searchBarState, inputField = inputField, navigationIcon = { - IconButton(onClick = onNavigateUp) { + IconButton(onClick = { activity?.onBackPressedDispatcher?.onBackPressed() }) { Icon( painter = painterResource(R.drawable.ic_arrow_back), contentDescription = stringResource(R.string.action_back) ) } }, - actions = { SetupMenu() }, + actions = { + IconButton(onClick = { sheetVisible = true }) { + Icon( + painter = painterResource(R.drawable.ic_tune), + contentDescription = stringResource(R.string.installed_sort_filter) + ) + } + SetupMenu() + }, colors = SearchBarDefaults.appBarWithSearchColors( appBarContainerColor = Color.Transparent ) @@ -248,8 +269,8 @@ private fun ScreenContent( .fillMaxWidth() .clickable { onRequestSearch(pkg.displayName) } .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_small) + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) ) ) } @@ -298,7 +319,7 @@ private fun ScreenContent( modifier = Modifier.fillMaxWidth(), state = listState, verticalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.margin_xxsmall) + dimensionResource(R.dimen.spacing_xsmall) ) ) { if (selectedPackages.isNotEmpty()) { @@ -333,12 +354,20 @@ private fun ScreenContent( } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } } } + + if (sheetVisible) { + SortFilterSheet( + state = sortFilterState, + installers = installers, + onStateChange = onSortFilterStateChange, + onDismiss = { sheetVisible = false } + ) + } } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/CategoryBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/CategoryBrowseScreen.kt new file mode 100644 index 000000000..93a1c7aa9 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/CategoryBrowseScreen.kt @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.store.R +import com.aurora.store.compose.composable.Placeholder +import com.aurora.store.compose.composable.StreamCarousel +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.composition.collectForced +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.data.model.ViewState +import com.aurora.store.viewmodel.subcategory.CategoryStreamViewModel + +@Composable +fun CategoryBrowseScreen( + title: String, + browseUrl: String, + onNavigateTo: (Destination) -> Unit, + viewModel: CategoryStreamViewModel = hiltViewModel( + creationCallback = { factory: CategoryStreamViewModel.Factory -> + factory.create(browseUrl) + } + ) +) { + val uiState by viewModel.viewState.collectForced(ViewState.Loading) + + Scaffold( + topBar = { TopAppBar(title = title) } + ) { paddingValues -> + if (uiState is ViewState.Error) { + Placeholder( + modifier = Modifier.padding(paddingValues), + painter = painterResource(R.drawable.ic_disclaimer), + message = stringResource(R.string.error) + ) + } else { + val bundle = (uiState as? ViewState.Success<*>)?.data as? StreamBundle + StreamCarousel( + modifier = Modifier.padding(paddingValues), + streamBundle = bundle, + filterSingleAppClusters = false, + onHeaderClick = { cluster -> + if (cluster.clusterBrowseUrl.isNotBlank()) { + onNavigateTo(Destination.StreamBrowse(cluster)) + } + }, + onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, + onClusterScrolled = { viewModel.fetchNextCluster(it) }, + onScrolledToEnd = { viewModel.fetchNextPage() } + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt new file mode 100644 index 000000000..4d9736c0d --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/ExpandedStreamBrowseScreen.kt @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.aurora.store.R +import com.aurora.store.compose.composable.ContainedLoadingIndicator +import com.aurora.store.compose.composable.Placeholder +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.viewmodel.browse.ExpandedStreamBrowseViewModel +import kotlin.uuid.Uuid + +@Composable +fun ExpandedStreamBrowseScreen( + browseUrl: String, + defaultTitle: String, + onNavigateTo: (Destination) -> Unit, + viewModel: ExpandedStreamBrowseViewModel = hiltViewModel( + creationCallback = { factory: ExpandedStreamBrowseViewModel.Factory -> + factory.create(browseUrl) + } + ) +) { + val title by viewModel.title.collectAsStateWithLifecycle() + val apps = viewModel.apps.collectAsLazyPagingItems() + + Scaffold( + topBar = { + TopAppBar( + title = title.ifBlank { defaultTitle } + ) + } + ) { paddingValues -> + when (apps.loadState.refresh) { + is LoadState.Loading -> ContainedLoadingIndicator() + + is LoadState.Error -> { + Placeholder( + modifier = Modifier.padding(paddingValues), + painter = painterResource(R.drawable.ic_disclaimer), + message = stringResource(R.string.error) + ) + } + + else -> { + if (apps.itemCount == 0) { + Placeholder( + modifier = Modifier.padding(paddingValues), + painter = painterResource(R.drawable.ic_disclaimer), + message = stringResource(R.string.no_apps_available) + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) + ) { + items( + count = apps.itemCount, + key = { Uuid.random().toString() } + ) { index -> + apps[index]?.let { app -> + LargeAppListItem( + app = app, + onClick = { + onNavigateTo(Destination.AppDetails(app.packageName)) + } + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/MoreSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/MoreSheet.kt new file mode 100644 index 000000000..416911a27 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/MoreSheet.kt @@ -0,0 +1,202 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.store.R +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.viewmodel.commons.MoreViewModel + +private data class MoreItem( + @StringRes val titleRes: Int, + @DrawableRes val iconRes: Int, + val onClick: () -> Unit +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoreSheet( + onDismiss: () -> Unit, + onNavigateTo: (Destination) -> Unit = {}, + viewModel: MoreViewModel = hiltViewModel() +) { + val mainItems = listOf( + MoreItem(R.string.title_apps_games, R.drawable.ic_apps) { + onNavigateTo(Destination.Installed) + }, + MoreItem(R.string.title_blacklist_manager, R.drawable.ic_blacklist) { + onNavigateTo(Destination.Blacklist) + }, + MoreItem(R.string.title_favourites_manager, R.drawable.ic_favorite_unchecked) { + onNavigateTo(Destination.Favourite) + }, + MoreItem(R.string.title_spoof_manager, R.drawable.ic_spoof) { + onNavigateTo(Destination.Spoof) + } + ) + val extraItems = listOf( + MoreItem(R.string.title_settings, R.drawable.ic_menu_settings) { + onNavigateTo(Destination.Settings) + }, + MoreItem(R.string.title_about, R.drawable.ic_menu_about) { onNavigateTo(Destination.About) } + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + AccountHeader( + viewModel = viewModel, + onNavigateToAccounts = { + onNavigateTo(Destination.Accounts) + onDismiss() + } + ) + + HorizontalDivider() + + Column( + Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_small), + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) { + mainItems.forEach { item -> + NavigationDrawerItem( + icon = { + Icon( + painter = painterResource(item.iconRes), + contentDescription = null + ) + }, + label = { Text(stringResource(item.titleRes)) }, + selected = false, + onClick = { + item.onClick() + onDismiss() + } + ) + } + } + + HorizontalDivider() + + Column( + Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_small), + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) { + extraItems.forEach { item -> + NavigationDrawerItem( + icon = { + Icon( + painter = painterResource(item.iconRes), + contentDescription = null + ) + }, + label = { Text(stringResource(item.titleRes)) }, + selected = false, + onClick = { + item.onClick() + onDismiss() + } + ) + } + } + + Spacer(Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun AccountHeader(viewModel: MoreViewModel, onNavigateToAccounts: () -> Unit) { + val context = LocalContext.current + val isAnonymous = viewModel.authProvider.isAnonymous + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.spacing_large)), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(viewModel.authProvider.authData?.userProfile?.artwork?.url) + .crossfade(true) + .build(), + contentDescription = null, + placeholder = painterResource(R.drawable.ic_account), + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(40.dp) + .clip(CircleShape) + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)) + ) { + Text( + text = if (isAnonymous) { + stringResource(R.string.account_anonymous) + } else { + viewModel.authProvider.authData?.userProfile?.name + ?: stringResource(R.string.status_unavailable) + }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = viewModel.authProvider.authData?.userProfile?.email + ?: stringResource(R.string.status_unavailable), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + IconButton(onClick = onNavigateToAccounts) { + Icon( + painter = painterResource(R.drawable.ic_account_manager), + contentDescription = stringResource(R.string.manage_account) + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/NetworkScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/NetworkScreen.kt new file mode 100644 index 000000000..f14143b5e --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/NetworkScreen.kt @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.provider.Settings +import android.util.Log +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.aurora.extensions.isQAndAbove +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +private const val TAG = "NetworkScreen" + +/** + * Full-screen "no network" placeholder shown while [com.aurora.store.data.model.NetworkStatus] + * is UNAVAILABLE. Replaces the legacy modal sheet. Auto-dismisses by virtue of its caller + * only rendering it while offline. + */ +@Composable +fun NetworkScreen() { + val context = LocalContext.current + + Scaffold { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = dimensionResource(R.dimen.spacing_xlarge)), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_network), + contentDescription = null, + modifier = Modifier.requiredSize(48.dp) + ) + Text( + text = stringResource(R.string.title_no_network), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.check_connectivity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + try { + if (isQAndAbove) { + context.startActivity( + Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY) + ) + } else { + context.startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) + } + } catch (_: ActivityNotFoundException) { + Log.i(TAG, "Unable to launch network settings") + try { + context.startActivity(Intent(Settings.ACTION_SETTINGS)) + } catch (_: Exception) { } + } + } + ) { + Text(stringResource(R.string.action_check)) + } + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun NetworkScreenPreview() { + NetworkScreen() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/PermissionRationaleScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/PermissionRationaleScreen.kt index 1a27bb5c6..3aad2fc3d 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/PermissionRationaleScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/PermissionRationaleScreen.kt @@ -35,14 +35,12 @@ import kotlin.random.Random @Composable fun PermissionRationaleScreen( requiredPermissions: Set = emptySet(), - onNavigateUp: () -> Unit, onPermissionCallback: (type: PermissionType) -> Unit = {}, viewModel: PermissionRationaleViewModel = hiltViewModel() ) { val permissions by viewModel.permissions.collectAsStateWithLifecycle() ScreenContent( - onNavigateUp = onNavigateUp, permissions = permissions .filter { it.type in requiredPermissions } .map { permission -> permission.copy(optional = false) }, @@ -56,7 +54,6 @@ fun PermissionRationaleScreen( @Composable private fun ScreenContent( permissions: List = emptyList(), - onNavigateUp: () -> Unit = {}, onPermissionCallback: (type: PermissionType) -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { @@ -64,8 +61,7 @@ private fun ScreenContent( topBar = { TopAppBar( title = pluralStringResource(R.plurals.permissions_required, permissions.size), - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) } ) { paddingValues -> diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilter.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilter.kt new file mode 100644 index 000000000..bf6c8def1 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilter.kt @@ -0,0 +1,139 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import android.content.Context +import com.aurora.store.R +import com.aurora.store.util.Preferences +import java.util.Locale + +/** + * User-controlled axis for ordering a list of installed apps. + */ +enum class SortBy { + NAME, + DATE_INSTALLED, + DATE_UPDATED, + PACKAGE_NAME, + SIZE +} + +enum class SortOrder { ASC, DESC } + +enum class AppType { USER, SYSTEM } + +/** + * Sort + filter state shared by screens that list installed apps. Persisted via + * [Preferences] using a per-screen [SortFilterPrefKeys] so each screen restores + * its own last selection. + * + * [installer] is the package name of the installing source to filter by, or null + * for "all installers". [appTypes] is a non-empty subset; the UI guards against + * the empty case by always keeping at least one type selected. + */ +data class SortFilterState( + val sortBy: SortBy = SortBy.NAME, + val sortOrder: SortOrder = SortOrder.ASC, + val appTypes: Set = setOf(AppType.USER), + val installer: String? = null +) + +/** + * SharedPreferences keys a screen uses to persist its [SortFilterState]. + */ +data class SortFilterPrefKeys( + val sortBy: String, + val sortOrder: String, + val appTypes: String, + val installer: String +) + +fun SortFilterState.save(context: Context, keys: SortFilterPrefKeys) { + Preferences.putString(context, keys.sortBy, sortBy.name) + Preferences.putString(context, keys.sortOrder, sortOrder.name) + Preferences.putStringSet(context, keys.appTypes, appTypes.map { it.name }.toSet()) + if (installer == null) { + Preferences.remove(context, keys.installer) + } else { + Preferences.putString(context, keys.installer, installer) + } +} + +fun loadSortFilterState(context: Context, keys: SortFilterPrefKeys): SortFilterState { + val sortBy = enumValueOrDefault(Preferences.getString(context, keys.sortBy), SortBy.NAME) + val sortOrder = enumValueOrDefault( + Preferences.getString(context, keys.sortOrder), + SortOrder.ASC + ) + val appTypes = Preferences.getStringSet(context, keys.appTypes) + .mapNotNull { runCatching { AppType.valueOf(it) }.getOrNull() } + .toSet() + .ifEmpty { setOf(AppType.USER) } + val installer = Preferences.getString(context, keys.installer).ifBlank { null } + return SortFilterState(sortBy, sortOrder, appTypes, installer) +} + +private inline fun > enumValueOrDefault(name: String, default: T): T = + runCatching { enumValueOf(name) }.getOrDefault(default) + +fun SortBy.labelRes(): Int = when (this) { + SortBy.NAME -> R.string.installed_sort_name + SortBy.DATE_INSTALLED -> R.string.installed_sort_date_installed + SortBy.DATE_UPDATED -> R.string.installed_sort_date_updated + SortBy.PACKAGE_NAME -> R.string.installed_sort_package_name + SortBy.SIZE -> R.string.installed_sort_size +} + +fun SortOrder.labelRes(): Int = when (this) { + SortOrder.ASC -> R.string.installed_sort_ascending + SortOrder.DESC -> R.string.installed_sort_descending +} + +fun AppType.labelRes(): Int = when (this) { + AppType.USER -> R.string.installed_filter_user_apps + AppType.SYSTEM -> R.string.installed_filter_system_apps +} + +/** + * Per-app metadata that [applyFilter] and [applySort] read. Each consumer screen + * has its own enriched model (e.g. `EnrichedPackage`, `BlacklistAppItem`) that + * implements this interface so the shared filter/sort logic stays type-safe. + */ +interface InstalledAppMeta { + val packageName: String + val label: String + val firstInstallTime: Long + val lastUpdateTime: Long + val sizeBytes: Long + val isSystem: Boolean + val installer: String? +} + +fun List.applyFilter(state: SortFilterState): List { + val typeFilter: (T) -> Boolean = when { + state.appTypes.containsAll(listOf(AppType.USER, AppType.SYSTEM)) -> { _ -> true } + AppType.USER in state.appTypes -> { pkg -> !pkg.isSystem } + AppType.SYSTEM in state.appTypes -> { pkg -> pkg.isSystem } + else -> { _ -> false } + } + val installerFilter: (T) -> Boolean = state.installer + ?.let { wanted -> { pkg: T -> pkg.installer == wanted } } + ?: { _ -> true } + return filter { typeFilter(it) && installerFilter(it) } +} + +fun List.applySort(state: SortFilterState): List { + val comparator: Comparator = when (state.sortBy) { + SortBy.NAME -> compareBy { it.label.lowercase(Locale.getDefault()) } + SortBy.DATE_INSTALLED -> compareBy { it.firstInstallTime } + SortBy.DATE_UPDATED -> compareBy { it.lastUpdateTime } + SortBy.PACKAGE_NAME -> compareBy { it.packageName.lowercase(Locale.getDefault()) } + SortBy.SIZE -> compareBy { it.sizeBytes } + } + return sortedWith( + if (state.sortOrder == SortOrder.ASC) comparator else comparator.reversed() + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilterSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilterSheet.kt new file mode 100644 index 000000000..7b7144901 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/SortFilterSheet.kt @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import androidx.compose.foundation.clickable +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.store.R +import com.aurora.store.compose.composable.SectionHeader +import com.aurora.store.compose.preview.ThemePreviewProvider + +/** + * Sheet that lets the user choose how a list of installed apps is sorted and + * filtered. Edits propagate immediately via [onStateChange]; the sheet itself is + * stateless. + * + * @param installers Map of installer package name to human-readable label, used + * to populate the installer filter chips. When empty the installer row is + * hidden. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun SortFilterSheet( + state: SortFilterState, + installers: Map, + onStateChange: (SortFilterState) -> Unit, + onDismiss: () -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + SortSection(state = state, onStateChange = onStateChange) + HorizontalDivider( + modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall)) + ) + FilterSection( + state = state, + installers = installers, + onStateChange = onStateChange + ) + Spacer(Modifier.navigationBarsPadding()) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SortSection(state: SortFilterState, onStateChange: (SortFilterState) -> Unit) { + SectionHeader(title = stringResource(R.string.installed_sort_by)) + SortBy.entries.forEach { option -> + SelectableRow( + label = stringResource(option.labelRes()), + selected = state.sortBy == option, + onClick = { onStateChange(state.copy(sortBy = option)) } + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SortOrder.entries.forEachIndexed { index, order -> + SegmentedButton( + selected = state.sortOrder == order, + onClick = { onStateChange(state.copy(sortOrder = order)) }, + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = SortOrder.entries.size + ) + ) { + Text(text = stringResource(order.labelRes())) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun FilterSection( + state: SortFilterState, + installers: Map, + onStateChange: (SortFilterState) -> Unit +) { + SectionHeader(title = stringResource(R.string.installed_filter)) + AppType.entries.forEach { type -> + CheckableRow( + label = stringResource(type.labelRes()), + checked = type in state.appTypes, + onCheckedChange = { checked -> + val next = if (checked) state.appTypes + type else state.appTypes - type + // Always keep at least one type selected to avoid an empty list. + if (next.isNotEmpty()) onStateChange(state.copy(appTypes = next)) + } + ) + } + if (installers.isNotEmpty()) { + Text( + text = stringResource(R.string.installed_filter_installer), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) + ) { + FilterChip( + selected = state.installer == null, + onClick = { onStateChange(state.copy(installer = null)) }, + label = { Text(text = stringResource(R.string.installed_filter_all)) } + ) + installers.entries.sortedBy { it.value.lowercase() }.forEach { (pkg, label) -> + FilterChip( + selected = state.installer == pkg, + onClick = { + onStateChange( + state.copy(installer = if (state.installer == pkg) null else pkg) + ) + }, + label = { Text(text = label) } + ) + } + } + } +} + +@Composable +private fun SelectableRow(label: String, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + RadioButton(selected = selected, onClick = onClick) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun CheckableRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) + ) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview(showBackground = true) +@Composable +private fun SortFilterSheetPreview() { + SortFilterSheet( + state = SortFilterState(), + installers = mapOf( + "com.android.vending" to "Google Play Store", + "com.aurora.store" to "Aurora Store", + "org.fdroid.fdroid" to "F-Droid" + ), + onStateChange = {}, + onDismiss = {} + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt index 34c2e6f8b..577990f2a 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/StreamBrowseScreen.kt @@ -5,12 +5,14 @@ package com.aurora.store.compose.ui.commons +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -26,9 +28,10 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator -import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.viewmodel.browse.StreamBrowseViewModel @@ -39,8 +42,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun StreamBrowseScreen( streamCluster: StreamCluster, - onNavigateUp: () -> Unit, - onNavigateToAppDetails: (packageName: String) -> Unit, + onNavigateTo: (Destination) -> Unit, viewModel: StreamBrowseViewModel = hiltViewModel( creationCallback = { factory: StreamBrowseViewModel.Factory -> factory.create(streamCluster) @@ -52,8 +54,7 @@ fun StreamBrowseScreen( ScreenContent( title = streamCluster.clusterTitle, apps = apps, - onNavigateUp = onNavigateUp, - onNavigateToAppDetails = onNavigateToAppDetails + onNavigateTo = onNavigateTo ) } @@ -61,19 +62,18 @@ fun StreamBrowseScreen( private fun ScreenContent( title: String = String(), apps: LazyPagingItems = emptyPagingItems(), - onNavigateToAppDetails: (packageName: String) -> Unit = {}, - onNavigateUp: () -> Unit = {} + onNavigateTo: (Destination) -> Unit = {} ) { Scaffold( topBar = { - TopAppBar(title = title, onNavigateUp = onNavigateUp) + TopAppBar(title = title) } ) { paddingValues -> when (apps.loadState.refresh) { is LoadState.Loading -> ContainedLoadingIndicator() is LoadState.Error -> { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), message = stringResource(R.string.error) @@ -82,7 +82,7 @@ private fun ScreenContent( else -> { if (apps.itemCount == 0) { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), message = stringResource(R.string.no_apps_available) @@ -91,7 +91,10 @@ private fun ScreenContent( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(paddingValues) + .padding(paddingValues), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { items( count = apps.itemCount, @@ -100,7 +103,9 @@ private fun ScreenContent( apps[index]?.let { app -> LargeAppListItem( app = app, - onClick = { onNavigateToAppDetails(app.packageName) } + onClick = { + onNavigateTo(Destination.AppDetails(app.packageName)) + } ) } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index a4b9342fb..b0bd2c83b 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -14,14 +14,11 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith 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.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 @@ -50,7 +47,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -63,15 +59,21 @@ import com.aurora.extensions.share import com.aurora.extensions.toast import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Review +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport import com.aurora.store.ComposeActivity import com.aurora.store.R +import com.aurora.store.compose.composable.ClusterRow import com.aurora.store.compose.composable.ContainedLoadingIndicator -import com.aurora.store.compose.composable.Error -import com.aurora.store.compose.composable.Header +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint +import com.aurora.store.compose.composable.SectionHeader +import com.aurora.store.compose.composable.ShimmerCarouselSection +import com.aurora.store.compose.composable.StreamCarousel import com.aurora.store.compose.composable.TopAppBar -import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.composition.collectForced +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider @@ -92,6 +94,7 @@ import com.aurora.store.compose.ui.details.menu.AppDetailsMenu import com.aurora.store.compose.ui.details.menu.MenuItem import com.aurora.store.compose.ui.details.navigation.ExtraScreen import com.aurora.store.compose.ui.dev.DevProfileScreen +import com.aurora.store.compose.ui.sheets.InstallErrorSheet import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.model.AppState import com.aurora.store.data.model.PermissionType @@ -104,14 +107,12 @@ import com.aurora.store.util.PackageUtil import com.aurora.store.util.ShortcutManagerUtil import com.aurora.store.viewmodel.details.AppDetailsViewModel import com.jakewharton.processphoenix.ProcessPhoenix -import kotlin.random.Random import kotlinx.coroutines.launch @Composable fun AppDetailsScreen( packageName: String, - onNavigateUp: () -> Unit, - onNavigateToAppDetails: (packageName: String) -> Unit, + onNavigateTo: (Destination) -> Unit, viewModel: AppDetailsViewModel = hiltViewModel(key = packageName), forceSinglePane: Boolean = false ) { @@ -124,10 +125,22 @@ fun AppDetailsScreen( val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle() val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle() val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() - val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() + val installError by viewModel.installError.collectAsStateWithLifecycle() + val suggestionsBundle by viewModel.suggestionsBundle.collectForced(initial = null) LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) } + app?.let { loadedApp -> + installError?.let { err -> + InstallErrorSheet( + app = loadedApp, + error = err.error, + extra = err.extra, + onDismiss = viewModel::dismissInstallError + ) + } + } + AnimatedContent( targetState = state, contentKey = { stateKey(it, app) }, @@ -136,11 +149,10 @@ fun AppDetailsScreen( ) { currentState -> when { currentState is AppState.Loading || app == null -> - ScreenContentLoading(onNavigateUp = onNavigateUp) + ScreenContentLoading() currentState is AppState.Error -> ScreenContentError( - onNavigateUp = onNavigateUp, message = currentState.message ) @@ -149,15 +161,15 @@ fun AppDetailsScreen( ScreenContentApp( app = loadedApp, featuredReviews = featuredReviews, - suggestions = suggestions, + suggestionsBundle = suggestionsBundle, isFavorite = favorite, isAnonymous = viewModel.authProvider.isAnonymous, state = currentState, plexusScores = plexusScores, dataSafetyReport = dataSafetyReport, exodusReport = exodusReport, - onNavigateUp = onNavigateUp, - onNavigateToAppDetails = onNavigateToAppDetails, + onNavigateTo = onNavigateTo, + onLoadMoreCluster = { cluster -> viewModel.loadMoreCluster(cluster) }, onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) }, onFavorite = { viewModel.toggleFavourite(loadedApp) }, onCancelDownload = { viewModel.cancelDownload(loadedApp) }, @@ -196,9 +208,9 @@ private fun stateKey(state: AppState, app: App?): String = when { * Composable to show progress while fetching app details */ @Composable -private fun ScreenContentLoading(onNavigateUp: () -> Unit = {}) { +private fun ScreenContentLoading() { Scaffold( - topBar = { TopAppBar(onNavigateUp = onNavigateUp) } + topBar = { TopAppBar() } ) { paddingValues -> ContainedLoadingIndicator(modifier = Modifier.padding(paddingValues)) } @@ -208,11 +220,11 @@ private fun ScreenContentLoading(onNavigateUp: () -> Unit = {}) { * Composable to display errors related to fetching app details */ @Composable -private fun ScreenContentError(onNavigateUp: () -> Unit = {}, message: String? = null) { +private fun ScreenContentError(message: String? = null) { Scaffold( - topBar = { TopAppBar(onNavigateUp = onNavigateUp) } + topBar = { TopAppBar() } ) { paddingValues -> - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_apps_outage), message = message ?: stringResource(R.string.toast_app_unavailable) @@ -227,15 +239,15 @@ private fun ScreenContentError(onNavigateUp: () -> Unit = {}, message: String? = private fun ScreenContentApp( app: App, featuredReviews: List = emptyList(), - suggestions: List = emptyList(), + suggestionsBundle: StreamBundle? = StreamBundle.EMPTY, isFavorite: Boolean = false, isAnonymous: Boolean = true, state: AppState = AppState.Unavailable, plexusScores: Scores? = null, dataSafetyReport: DataSafetyReport? = null, exodusReport: Report? = null, - onNavigateUp: () -> Unit = {}, - onNavigateToAppDetails: (packageName: String) -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {}, + onLoadMoreCluster: (cluster: StreamCluster) -> Unit = {}, onDownload: (requestedApp: App) -> Unit = {}, onFavorite: () -> Unit = {}, onCancelDownload: () -> Unit = {}, @@ -412,7 +424,6 @@ private fun ScreenContentApp( Scaffold( topBar = { TopAppBar( - onNavigateUp = onNavigateUp, actions = { if (shouldShowMenuOnMainPane) SetupMenu() } ) } @@ -427,7 +438,7 @@ private fun ScreenContentApp( modifier = Modifier .fillMaxSize(), verticalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.margin_medium) + dimensionResource(R.dimen.spacing_medium) ), state = listState ) { @@ -452,7 +463,7 @@ private fun ScreenContentApp( } item { - Header( + SectionHeader( title = stringResource(R.string.details_more_about_app), subtitle = app.shortDescription, onClick = { showExtraPane(ExtraScreen.More) } @@ -488,7 +499,7 @@ private fun ScreenContentApp( } item { - Header( + SectionHeader( title = stringResource(R.string.details_permission), subtitle = if (app.permissions.isNotEmpty()) { stringResource(R.string.permissions_requested, app.permissions.size) @@ -532,10 +543,17 @@ private fun ScreenContentApp( email = app.developerEmail ) } + + if (shouldShowMenuOnMainPane) { + suggestionClusterItems( + suggestionsBundle = suggestionsBundle, + onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, + onClusterScrolled = onLoadMoreCluster + ) + } } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -546,38 +564,22 @@ private fun ScreenContentApp( fun SupportingPane() { Scaffold( topBar = { - TopAppBar(actions = { if (!shouldShowMenuOnMainPane) SetupMenu() }) + TopAppBar( + showNavigationIcon = false, + actions = { if (!shouldShowMenuOnMainPane) SetupMenu() } + ) } ) { paddingValues -> - if (suggestions.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - Row( - modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.ic_suggestions), - contentDescription = null - ) - Header(title = stringResource(R.string.pref_ui_similar_apps)) - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)) - ) { - items(items = suggestions, key = { item -> item.id }) { app -> - LargeAppListItem( - app = app, - onClick = { onNavigateToAppDetails(app.packageName) } - ) - } - } - } + val hasContent = suggestionsBundle == null || + suggestionsBundle.streamClusters.isNotEmpty() + if (hasContent) { + StreamCarousel( + modifier = Modifier.padding(paddingValues), + streamBundle = suggestionsBundle, + filterSingleAppClusters = false, + onAppClick = { onNavigateTo(Destination.AppDetails(it.packageName)) }, + onClusterScrolled = onLoadMoreCluster + ) } } } @@ -585,52 +587,43 @@ private fun ScreenContentApp( @Composable fun ExtraPane(screen: NavKey) = when (screen) { is ExtraScreen.Review -> ReviewScreen( - packageName = app.packageName, - onNavigateUp = ::onNavigateBack + packageName = app.packageName ) is ExtraScreen.Exodus -> ExodusScreen( - packageName = app.packageName, - onNavigateUp = ::onNavigateBack + packageName = app.packageName ) is ExtraScreen.More -> MoreScreen( packageName = app.packageName, - onNavigateUp = ::onNavigateBack, - onNavigateToAppDetails = onNavigateToAppDetails + onNavigateTo = onNavigateTo ) is ExtraScreen.Permission -> PermissionScreen( - packageName = app.packageName, - onNavigateUp = ::onNavigateBack + packageName = app.packageName ) is ExtraScreen.Screenshot -> ScreenshotScreen( packageName = app.packageName, - index = screen.index, - onNavigateUp = ::onNavigateBack + index = screen.index ) is ExtraScreen.ManualDownload -> ManualDownloadScreen( packageName = app.packageName, - onNavigateUp = ::onNavigateBack, onRequestInstall = { requestedApp -> onInstall(requestedApp) } ) is ExtraScreen.MicroG -> MicroGScreen( packageName = app.packageName, - onNavigateUp = ::onNavigateBack, onProceed = { onInstall(ignoreMicroG = true) } ) is Screen.DevProfile -> DevProfileScreen( publisherId = app.developerName, - onNavigateUp = ::onNavigateBack, - onNavigateToAppDetails = { onNavigateToAppDetails(it) } + onNavigateTo = onNavigateTo ) is Screen.PermissionRationale -> PermissionRationaleScreen( - onNavigateUp = ::onNavigateBack, requiredPermissions = screen.requiredPermissions, onPermissionCallback = { type -> val isStoragePermission = type == PermissionType.EXTERNAL_STORAGE || @@ -658,6 +651,38 @@ private fun ScreenContentApp( ) } +/** + * Renders the suggestion stream as cluster rows inside the parent [LazyColumn]. + * Shows a shimmer placeholder while the bundle is still loading (`null`). + */ +private fun LazyListScope.suggestionClusterItems( + suggestionsBundle: StreamBundle?, + onAppClick: (App) -> Unit, + onClusterScrolled: (StreamCluster) -> Unit +) { + if (suggestionsBundle == null) { + item(key = "suggestions-shimmer") { ShimmerCarouselSection() } + return + } + + val clusters = suggestionsBundle.streamClusters.values.filter { + it.clusterTitle.isNotBlank() && it.clusterAppList.isNotEmpty() + } + + clusters.forEach { cluster -> + item(key = "cluster-header-${cluster.id}") { + SectionHeader(title = cluster.clusterTitle) + } + item(key = "cluster-row-${cluster.id}") { + ClusterRow( + cluster = cluster, + onAppClick = onAppClick, + onClusterScrolled = onClusterScrolled + ) + } + } +} + @PreviewWrapper(ThemePreviewProvider::class) @PreviewScreenSizes @Composable @@ -665,7 +690,20 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class) ScreenContentApp( app = app, isAnonymous = false, - suggestions = List(10) { app.copy(id = Random.nextInt()) } + suggestionsBundle = StreamBundle( + streamClusters = mapOf( + 1 to StreamCluster( + id = 1, + clusterTitle = "Similar apps", + clusterAppList = List(8) { app.copy(id = it) } + ), + 2 to StreamCluster( + id = 2, + clusterTitle = "More by ${app.developerName}", + clusterAppList = List(5) { app.copy(id = 100 + it) } + ) + ) + ) ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt index f85046245..68f78cbac 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.Constants.EXODUS_REPORT_URL @@ -41,9 +40,9 @@ import com.aurora.extensions.browse import com.aurora.extensions.isWindowCompact import com.aurora.gplayapi.data.models.App import com.aurora.store.R -import com.aurora.store.compose.composable.Error -import com.aurora.store.compose.composable.Header +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.details.ExodusListItem import com.aurora.store.compose.preview.AppPreviewProvider @@ -56,7 +55,6 @@ import com.aurora.store.viewmodel.details.ExodusViewModel @Composable fun ExodusScreen( packageName: String, - onNavigateUp: () -> Unit, appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), exodusViewModel: ExodusViewModel = hiltViewModel(key = "$packageName/exodus"), windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() @@ -80,7 +78,6 @@ fun ExodusScreen( null -> { ScreenContentError( topAppBarTitle = topAppBarTitle, - onNavigateUp = onNavigateUp, onRequestAnalysis = { context.browse("${EXODUS_SUBMIT_PAGE}${app!!.packageName}") } ) } @@ -90,7 +87,6 @@ fun ExodusScreen( topAppBarTitle = topAppBarTitle, report = report, trackers = trackers, - onNavigateUp = onNavigateUp, onRequestAnalysis = { context.browse("${EXODUS_SUBMIT_PAGE}${app!!.packageName}") } ) } @@ -102,7 +98,6 @@ private fun ScreenContentReport( topAppBarTitle: String? = null, report: Report? = null, trackers: List = emptyList(), - onNavigateUp: () -> Unit = {}, onRequestAnalysis: () -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { @@ -112,8 +107,7 @@ private fun ScreenContentReport( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) }, floatingActionButton = { @@ -134,12 +128,12 @@ private fun ScreenContentReport( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)), + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), state = listState ) { stickyHeader { Surface(modifier = Modifier.fillMaxWidth()) { - Header( + SectionHeader( title = if (report?.trackers.isNullOrEmpty()) { stringResource(R.string.exodus_no_tracker) } else { @@ -161,7 +155,6 @@ private fun ScreenContentReport( } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -174,7 +167,6 @@ private fun ScreenContentReport( @Composable private fun ScreenContentError( topAppBarTitle: String? = null, - onNavigateUp: () -> Unit = {}, onRequestAnalysis: () -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { @@ -182,16 +174,15 @@ private fun ScreenContentError( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) } ) { paddingValues -> - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), message = stringResource(R.string.failed_to_fetch_report), - actionMessage = stringResource(R.string.action_request_analysis), + actionLabel = stringResource(R.string.action_request_analysis), onAction = onRequestAnalysis ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt index 81384ac1e..314ee138f 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt @@ -6,6 +6,8 @@ package com.aurora.store.compose.ui.details +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -69,7 +71,6 @@ import kotlinx.coroutines.launch @Composable fun ManualDownloadScreen( packageName: String, - onNavigateUp: () -> Unit, onRequestInstall: (requestedApp: App) -> Unit, viewModel: AppDetailsViewModel = hiltViewModel(key = packageName), windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() @@ -85,7 +86,6 @@ fun ManualDownloadScreen( state = state, topAppBarTitle = topAppBarTitle, currentVersionCode = app!!.versionCode, - onNavigateUp = onNavigateUp, onRequestInstall = { versionCode -> val requestedApp = app!!.copy( versionCode = versionCode, @@ -105,10 +105,10 @@ private fun ScreenContent( state: AppState = AppState.Unavailable, topAppBarTitle: String? = null, currentVersionCode: Long = 0L, - onNavigateUp: () -> Unit = {}, onRequestInstall: (versionCode: Long) -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { + val activity = LocalActivity.current as? ComponentActivity val coroutineScope = rememberCoroutineScope() val snackBarHostState = remember { SnackbarHostState() } val errorMessage = stringResource(R.string.manual_download_version_error) @@ -130,8 +130,7 @@ private fun ScreenContent( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) }, snackbarHost = { SnackbarHost(hostState = snackBarHostState) } @@ -140,12 +139,14 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(dimensionResource(R.dimen.padding_medium)), + .padding(dimensionResource(R.dimen.spacing_medium)), verticalArrangement = Arrangement.SpaceBetween ) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { Info( painter = painterResource(R.drawable.ic_download_manager), @@ -182,12 +183,12 @@ private fun ScreenContent( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.padding_medium) + dimensionResource(R.dimen.spacing_medium) ) ) { FilledTonalButton( modifier = Modifier.weight(1F), - onClick = onNavigateUp + onClick = { activity?.onBackPressedDispatcher?.onBackPressed() } ) { Text( text = stringResource(R.string.action_close), diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt index 145c6cda9..82f17f2fb 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt @@ -6,6 +6,8 @@ package com.aurora.store.compose.ui.details +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -55,7 +57,6 @@ import com.aurora.store.viewmodel.onboarding.MicroGViewModel @Composable fun MicroGScreen( packageName: String, - onNavigateUp: () -> Unit, onProceed: () -> Unit, appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), viewModel: MicroGViewModel = hiltViewModel(), @@ -70,7 +71,6 @@ fun MicroGScreen( ScreenContent( topAppBarTitle = topAppBarTitle, uiState = viewModel.uiState, - onNavigateUp = onNavigateUp, onInstall = { viewModel.downloadMicroG() }, onRetry = { viewModel.retryDownload() }, onProceed = onProceed @@ -81,12 +81,12 @@ fun MicroGScreen( private fun ScreenContent( topAppBarTitle: String? = null, uiState: MicroGUIState = MicroGUIState(), - onNavigateUp: () -> Unit = {}, onInstall: () -> Unit = {}, onRetry: () -> Unit = {}, onProceed: () -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { + val activity = LocalActivity.current as? ComponentActivity val context = LocalContext.current val listState = rememberLazyListState() @@ -94,8 +94,7 @@ private fun ScreenContent( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) }, bottomBar = { @@ -104,15 +103,15 @@ private fun ScreenContent( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)) - .padding(vertical = dimensionResource(R.dimen.padding_small)), + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)) + .padding(vertical = dimensionResource(R.dimen.spacing_small)), horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.padding_medium) + dimensionResource(R.dimen.spacing_medium) ) ) { FilledTonalButton( modifier = Modifier.weight(1F), - onClick = onNavigateUp + onClick = { activity?.onBackPressedDispatcher?.onBackPressed() } ) { Text( text = stringResource(R.string.action_cancel), @@ -148,7 +147,7 @@ private fun ScreenContent( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)), + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), state = listState ) { item { @@ -174,7 +173,6 @@ private fun ScreenContent( ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/MoreScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/MoreScreen.kt index f9a66ccaa..3995c106d 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/MoreScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/MoreScreen.kt @@ -32,18 +32,18 @@ import androidx.compose.ui.text.fromHtml import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.extensions.adaptiveNavigationIcon import com.aurora.extensions.isWindowCompact import com.aurora.gplayapi.data.models.App import com.aurora.store.R -import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.Info import com.aurora.store.compose.composable.ScrollHint +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.app.AppListItem +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.viewmodel.details.AppDetailsViewModel @@ -52,8 +52,7 @@ import com.aurora.store.viewmodel.details.MoreViewModel @Composable fun MoreScreen( packageName: String, - onNavigateUp: () -> Unit, - onNavigateToAppDetails: (packageName: String) -> Unit, + onNavigateTo: (Destination) -> Unit, appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), moreViewModel: MoreViewModel = hiltViewModel( key = "$packageName/more", @@ -68,8 +67,7 @@ fun MoreScreen( ScreenContent( app = app!!, dependencies = dependencies, - onNavigateUp = onNavigateUp, - onNavigateToAppDetails = onNavigateToAppDetails + onNavigateTo = onNavigateTo ) } @@ -77,8 +75,7 @@ fun MoreScreen( private fun ScreenContent( app: App, dependencies: List? = null, - onNavigateUp: () -> Unit = {}, - onNavigateToAppDetails: (packageName: String) -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { val topAppBarTitle = when { @@ -90,8 +87,7 @@ private fun ScreenContent( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) } ) { paddingValues -> @@ -104,16 +100,18 @@ private fun ScreenContent( LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { item { - Header(title = stringResource(R.string.details_description)) + SectionHeader(title = stringResource(R.string.details_description)) } item { Text( modifier = Modifier.padding( - horizontal = dimensionResource(R.dimen.padding_medium) + horizontal = dimensionResource(R.dimen.spacing_medium) ), text = AnnotatedString.fromHtml( htmlString = app.description @@ -126,7 +124,7 @@ private fun ScreenContent( if (dependencies != null) { AppDependencies( dependencies = dependencies, - onNavigateToAppDetails = onNavigateToAppDetails + onNavigateTo = onNavigateTo ) } } @@ -137,7 +135,6 @@ private fun ScreenContent( } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -148,11 +145,8 @@ private fun ScreenContent( * Composable to show dependencies of an app */ @Composable -private fun AppDependencies( - dependencies: List, - onNavigateToAppDetails: (packageName: String) -> Unit -) { - Header(title = stringResource(R.string.details_dependencies)) +private fun AppDependencies(dependencies: List, onNavigateTo: (Destination) -> Unit) { + SectionHeader(title = stringResource(R.string.details_dependencies)) if (dependencies.isEmpty()) { Info( title = AnnotatedString(text = stringResource(R.string.details_no_dependencies)) @@ -162,7 +156,7 @@ private fun AppDependencies( items(items = dependencies, key = { item -> item.id }) { app -> AppListItem( app = app, - onClick = { onNavigateToAppDetails(app.packageName) } + onClick = { onNavigateTo(Destination.AppDetails(app.packageName)) } ) } } @@ -174,7 +168,7 @@ private fun AppDependencies( */ @Composable private fun AppInfoMore(app: App) { - Header(title = stringResource(R.string.details_more_info)) + SectionHeader(title = stringResource(R.string.details_more_info)) Info( title = AnnotatedString( text = stringResource(R.string.details_more_package_name) diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/PermissionScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/PermissionScreen.kt index d3d52194f..e0893795a 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/PermissionScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/PermissionScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.extensions.adaptiveNavigationIcon @@ -47,7 +46,6 @@ import com.aurora.store.viewmodel.details.PermissionViewModel @Composable fun PermissionScreen( packageName: String, - onNavigateUp: () -> Unit, appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), permissionViewModel: PermissionViewModel = hiltViewModel( key = "$packageName/permission", @@ -67,7 +65,6 @@ fun PermissionScreen( ScreenContent( topAppBarTitle = topAppBarTitle, - onNavigateUp = onNavigateUp, permissionsInfo = permissionsInfo ) } @@ -76,7 +73,6 @@ fun PermissionScreen( private fun ScreenContent( topAppBarTitle: String? = null, permissionsInfo: Map = emptyMap(), - onNavigateUp: () -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { val packageManager = LocalContext.current.packageManager @@ -85,8 +81,7 @@ private fun ScreenContent( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) } ) { paddingValues -> @@ -99,9 +94,11 @@ private fun ScreenContent( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)), + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), state = listState, - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { items(items = permissionsInfo.keys.toList(), key = { it }) { permission -> val permissionInfo = permissionsInfo.getValue(permission) @@ -127,7 +124,6 @@ private fun ScreenContent( } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt index 79b295803..f7bf883b7 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ReviewScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState @@ -50,7 +49,7 @@ import com.aurora.extensions.isWindowCompact import com.aurora.gplayapi.data.models.Review import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator -import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.details.ReviewListItem @@ -64,7 +63,6 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun ReviewScreen( packageName: String, - onNavigateUp: () -> Unit, appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), reviewViewModel: ReviewViewModel = hiltViewModel( key = "$packageName/review", @@ -85,7 +83,6 @@ fun ReviewScreen( ScreenContent( topAppBarTitle = topAppBarTitle, reviews = reviews, - onNavigateUp = onNavigateUp, onFilter = { filter -> reviewViewModel.fetchReviews(filter) } ) } @@ -93,7 +90,6 @@ fun ReviewScreen( @Composable private fun ScreenContent( topAppBarTitle: String? = null, - onNavigateUp: () -> Unit = {}, reviews: LazyPagingItems = emptyPagingItems(), onFilter: (filter: Review.Filter) -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() @@ -103,8 +99,7 @@ private fun ScreenContent( Column { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) FilterHeader { filter -> onFilter(filter) } } @@ -119,7 +114,7 @@ private fun ScreenContent( is LoadState.Loading -> ContainedLoadingIndicator() is LoadState.Error -> { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), message = stringResource(R.string.error) @@ -142,7 +137,6 @@ private fun ScreenContent( } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -173,8 +167,8 @@ private fun FilterHeader(onClick: (filter: Review.Filter) -> Unit) { LazyRow( modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.margin_normal)), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)) + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.spacing_medium)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { items(items = filters.keys.toList(), key = { item -> item }) { filter -> val selected = activeFilter == filter diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ScreenshotScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ScreenshotScreen.kt index ab808a78e..02821a278 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ScreenshotScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ScreenshotScreen.kt @@ -17,7 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper @@ -36,11 +36,11 @@ import com.aurora.store.viewmodel.details.AppDetailsViewModel fun ScreenshotScreen( packageName: String, index: Int, - onNavigateUp: () -> Unit, viewModel: AppDetailsViewModel = hiltViewModel(key = packageName), windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { val app by viewModel.app.collectAsStateWithLifecycle() + val screenshots = app?.screenshots ?: emptyList() val topAppBarTitle = when { windowAdaptiveInfo.isWindowCompact -> app!!.displayName @@ -49,8 +49,7 @@ fun ScreenshotScreen( ScreenContent( topAppBarTitle = topAppBarTitle, - onNavigateUp = onNavigateUp, - screenshots = app!!.screenshots, + screenshots = screenshots.distinctBy { it.url }, index = index ) } @@ -58,12 +57,11 @@ fun ScreenshotScreen( @Composable private fun ScreenContent( topAppBarTitle: String? = null, - onNavigateUp: () -> Unit = {}, screenshots: List = emptyList(), index: Int = 0, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { - val displayMetrics = LocalContext.current.resources.displayMetrics + val displayMetrics = LocalResources.current.displayMetrics val pagerState = rememberPagerState(initialPage = index) { screenshots.size } LaunchedEffect(key1 = index) { @@ -74,8 +72,7 @@ private fun ScreenContent( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) } ) { paddingValues -> @@ -83,7 +80,8 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize(), - state = pagerState + state = pagerState, + key = { screenshots[it].url } ) { page -> val artwork = screenshots[page] ScreenshotListItem( diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Actions.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Actions.kt index d74fbbe83..002d245ba 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Actions.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Actions.kt @@ -53,8 +53,8 @@ fun Actions( Row( modifier = Modifier .fillMaxWidth() - .padding(PaddingValues(horizontal = dimensionResource(R.dimen.padding_medium))), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)) + .padding(PaddingValues(horizontal = dimensionResource(R.dimen.spacing_medium))), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { val buttonWidthModifier = when { windowAdaptiveInfo.isWindowCompact -> Modifier.weight(1F) @@ -92,7 +92,7 @@ fun Actions( @Composable private fun ActionsPreview() { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Actions( primaryActionDisplayName = stringResource(R.string.action_install), diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Changelog.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Changelog.kt index f3775c0b9..cb2d495fc 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Changelog.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Changelog.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper import com.aurora.gplayapi.data.models.App import com.aurora.store.R -import com.aurora.store.compose.composable.Header +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider @@ -38,11 +38,11 @@ import com.aurora.store.compose.preview.ThemePreviewProvider */ @Composable fun Changelog(changelog: String) { - Header(title = stringResource(R.string.details_changelog)) + SectionHeader(title = stringResource(R.string.details_changelog)) Box( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(R.dimen.padding_medium)) + .padding(dimensionResource(R.dimen.spacing_medium)) ) { Box( modifier = Modifier @@ -50,8 +50,8 @@ fun Changelog(changelog: String) { .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) .background(color = MaterialTheme.colorScheme.secondaryContainer) .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_small) + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) ) ) { Text( @@ -71,7 +71,7 @@ fun Changelog(changelog: String) { @Composable private fun ChangelogPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Changelog(changelog = app.changes) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Compatibility.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Compatibility.kt index 310d4cf91..0e036f80b 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Compatibility.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Compatibility.kt @@ -15,8 +15,8 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.aurora.store.R -import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.Info +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.theme.successColor import com.aurora.store.compose.theme.warningColor @@ -30,7 +30,7 @@ import com.aurora.store.data.model.Scores */ @Composable fun Compatibility(needsGms: Boolean, plexusScores: Scores? = null) { - Header( + SectionHeader( title = stringResource(R.string.details_compatibility_title), subtitle = stringResource(R.string.plexus_powered) ) @@ -82,7 +82,7 @@ fun Compatibility(needsGms: Boolean, plexusScores: Scores? = null) { @Composable private fun CompatibilityPreview() { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Compatibility(needsGms = true) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/DataSafety.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/DataSafety.kt index 94b03fe5a..7f986bc6b 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/DataSafety.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/DataSafety.kt @@ -22,8 +22,8 @@ import com.aurora.gplayapi.data.models.datasafety.Entry import com.aurora.gplayapi.data.models.datasafety.EntryType import com.aurora.gplayapi.data.models.datasafety.Report import com.aurora.store.R -import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.Info +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider @@ -37,7 +37,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider fun DataSafety(report: Report, privacyPolicyUrl: String) { val context = LocalContext.current - Header( + SectionHeader( title = stringResource(R.string.details_data_safety_title), subtitle = stringResource(R.string.details_data_safety_subtitle), onClick = { context.browse(privacyPolicyUrl) } @@ -86,7 +86,7 @@ fun DataSafety(report: Report, privacyPolicyUrl: String) { @Composable private fun DataSafetyPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { DataSafety( privacyPolicyUrl = app.privacyPolicyUrl, diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt index ad26a55c8..71b6c88d7 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Details.kt @@ -109,7 +109,7 @@ fun Details( Row( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(R.dimen.margin_medium)) + .padding(dimensionResource(R.dimen.spacing_medium)) ) { AnimatedAppIcon( modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_large)), @@ -117,7 +117,7 @@ fun Details( inProgress = state.inProgress(), progress = state.progress() ) - Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small))) { + Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.spacing_small))) { Text( text = app.displayName, style = MaterialTheme.typography.titleLarge, @@ -172,7 +172,7 @@ fun Details( @Composable private fun DetailsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Details(app = app) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/DeveloperDetails.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/DeveloperDetails.kt index fd89cba51..7b7ffe0cf 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/DeveloperDetails.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/DeveloperDetails.kt @@ -22,8 +22,8 @@ import com.aurora.extensions.copyToClipBoard import com.aurora.extensions.mailTo import com.aurora.gplayapi.data.models.App import com.aurora.store.R -import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.Info +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider @@ -38,7 +38,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider fun DeveloperDetails(address: String, website: String, email: String) { val context = LocalContext.current - Header(title = stringResource(R.string.details_dev_details)) + SectionHeader(title = stringResource(R.string.details_dev_details)) Column { if (website.isNotBlank()) { Info( @@ -74,7 +74,7 @@ fun DeveloperDetails(address: String, website: String, email: String) { @Composable private fun DeveloperDetailsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { DeveloperDetails( address = app.developerAddress, diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Privacy.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Privacy.kt index 9a0b2fd58..0fdaed4b5 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Privacy.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Privacy.kt @@ -15,8 +15,8 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.aurora.store.R -import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.Info +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.theme.successColor import com.aurora.store.compose.theme.warningColor @@ -30,7 +30,7 @@ import com.aurora.store.data.model.Report */ @Composable fun Privacy(report: Report?, onNavigateToDetailsExodus: (() -> Unit)? = null) { - Header( + SectionHeader( title = stringResource(R.string.details_privacy), subtitle = stringResource(R.string.exodus_powered), onClick = onNavigateToDetailsExodus @@ -64,7 +64,7 @@ fun Privacy(report: Report?, onNavigateToDetailsExodus: (() -> Unit)? = null) { @Composable private fun PrivacyPreview() { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Privacy(report = Report(), onNavigateToDetailsExodus = {}) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/RatingAndReviews.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/RatingAndReviews.kt index f4e1b379e..cb2663d27 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/RatingAndReviews.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/RatingAndReviews.kt @@ -40,7 +40,7 @@ import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Rating import com.aurora.gplayapi.data.models.Review import com.aurora.store.R -import com.aurora.store.compose.composable.Header +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.composable.details.RatingListItem import com.aurora.store.compose.composable.details.ReviewListItem import com.aurora.store.compose.preview.AppPreviewProvider @@ -82,7 +82,7 @@ fun RatingAndReviews( } } - Header( + SectionHeader( title = stringResource(R.string.details_ratings), onClick = onNavigateToDetailsReview ) @@ -90,12 +90,12 @@ fun RatingAndReviews( Row( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(R.dimen.padding_medium)), + .padding(dimensionResource(R.dimen.spacing_medium)), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_large)) + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_large)) ) { Column( - modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)), + modifier = Modifier.padding(dimensionResource(R.dimen.spacing_medium)), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -114,7 +114,7 @@ fun RatingAndReviews( } Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) ) { stars.reversed().fastForEach { star -> RatingListItem( @@ -129,8 +129,8 @@ fun RatingAndReviews( val pagerState = rememberPagerState { featuredReviews.size } HorizontalPager( state = pagerState, - contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.padding_medium)), - pageSpacing = dimensionResource(R.dimen.margin_medium) + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.spacing_medium)), + pageSpacing = dimensionResource(R.dimen.spacing_medium) ) { page -> Box( modifier = Modifier @@ -158,7 +158,7 @@ private fun RatingAndReviewsPreview(@PreviewParameter(AppPreviewProvider::class) ) } Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { RatingAndReviews(rating = app.rating, featuredReviews = reviews) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Screenshots.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Screenshots.kt index a325d69a0..3cfe4884f 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Screenshots.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Screenshots.kt @@ -37,8 +37,8 @@ import com.aurora.store.compose.preview.ThemePreviewProvider @Composable fun Screenshots(screenshots: List, onNavigateToScreenshot: (index: Int) -> Unit = {}) { LazyRow( - contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.padding_medium)), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.spacing_medium)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) ) { items(items = screenshots, key = { artwork -> artwork.url }) { artwork -> ScreenshotListItem( @@ -57,7 +57,7 @@ fun Screenshots(screenshots: List, onNavigateToScreenshot: (index: Int) @Composable private fun ScreenshotsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Screenshots(screenshots = app.screenshots) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Tags.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Tags.kt index 12ffef31d..3c489e345 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Tags.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Tags.kt @@ -63,8 +63,8 @@ fun Tags(app: App) { ).filterKeys { it != null } LazyRow( - contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.padding_medium)), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)) + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.spacing_medium)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { items(items = tags.keys.toList()) { label -> TagListItem(label = label!!, painter = painterResource(tags.getValue(label))) @@ -77,7 +77,7 @@ fun Tags(app: App) { @Composable private fun TagsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Tags(app = app) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Testing.kt b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Testing.kt index 58d3bc4d1..8a5d54c98 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/composable/Testing.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/composable/Testing.kt @@ -26,8 +26,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper import com.aurora.gplayapi.data.models.App import com.aurora.store.R -import com.aurora.store.compose.composable.Header import com.aurora.store.compose.composable.Info +import com.aurora.store.compose.composable.SectionHeader import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider @@ -40,13 +40,13 @@ import com.aurora.store.compose.preview.ThemePreviewProvider */ @Composable fun Testing(isSubscribed: Boolean, onTestingSubscriptionChange: (subscribe: Boolean) -> Unit = {}) { - Header(title = stringResource(R.string.details_beta)) + SectionHeader(title = stringResource(R.string.details_beta)) Row( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(R.dimen.padding_medium)), + .padding(dimensionResource(R.dimen.spacing_medium)), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)) ) { Info( modifier = Modifier.weight(1F), @@ -79,7 +79,7 @@ fun Testing(isSubscribed: Boolean, onTestingSubscriptionChange: (subscribe: Bool @Composable private fun TestingPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { Testing(isSubscribed = app.testingProgram!!.isSubscribed) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt index 054f55c8f..ef5e0fb5b 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/dev/DevProfileScreen.kt @@ -6,6 +6,7 @@ package com.aurora.store.compose.ui.dev +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -31,9 +32,10 @@ import com.aurora.extensions.adaptiveNavigationIcon import com.aurora.gplayapi.data.models.App import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator -import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.viewmodel.details.DevProfileViewModel @@ -48,8 +50,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun DevProfileScreen( developerId: String, - onNavigateUp: () -> Unit, - onNavigateToAppDetails: (packageName: String) -> Unit, + onNavigateTo: (Destination) -> Unit, viewModel: DevProfileViewModel = hiltViewModel() ) { // TODO: Implement when migrating logic for current DevProfileFragment @@ -62,8 +63,7 @@ fun DevProfileScreen( @Composable fun DevProfileScreen( publisherId: String, - onNavigateUp: () -> Unit, - onNavigateToAppDetails: (packageName: String) -> Unit, + onNavigateTo: (Destination) -> Unit, viewModel: SearchViewModel = hiltViewModel() ) { val apps = viewModel.apps.collectAsLazyPagingItems() @@ -73,8 +73,7 @@ fun DevProfileScreen( ScreenContent( apps = apps, topAppBarTitle = publisherId, - onNavigateUp = onNavigateUp, - onNavigateToAppDetails = onNavigateToAppDetails + onNavigateTo = onNavigateTo ) } @@ -82,16 +81,14 @@ fun DevProfileScreen( private fun ScreenContent( apps: LazyPagingItems, topAppBarTitle: String, - onNavigateUp: () -> Unit = {}, - onNavigateToAppDetails: (packageName: String) -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2() ) { Scaffold( topBar = { TopAppBar( title = topAppBarTitle, - navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, - onNavigateUp = onNavigateUp + navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon ) } ) { paddingValues -> @@ -99,7 +96,7 @@ private fun ScreenContent( is LoadState.Loading -> ContainedLoadingIndicator() is LoadState.Error -> { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), message = stringResource(R.string.error) @@ -111,13 +108,16 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)) + .padding(vertical = dimensionResource(R.dimen.spacing_medium)), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { items(count = apps.itemCount, key = apps.itemKey { it.id }) { index -> apps[index]?.let { app -> LargeAppListItem( app = app, - onClick = { onNavigateToAppDetails(app.packageName) } + onClick = { onNavigateTo(Destination.AppDetails(app.packageName)) } ) } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/dispenser/DispenserScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/dispenser/DispenserScreen.kt index 25645ad44..0896c6b5d 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/dispenser/DispenserScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/dispenser/DispenserScreen.kt @@ -29,13 +29,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.extensions.copyToClipBoard import com.aurora.store.R import com.aurora.store.compose.composable.DispenserListItem -import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.viewmodel.dispenser.DispenserViewModel @Composable -fun DispenserScreen(onNavigateUp: () -> Unit, viewModel: DispenserViewModel = hiltViewModel()) { +fun DispenserScreen(viewModel: DispenserViewModel = hiltViewModel()) { val dispensers by viewModel.dispensers.collectAsStateWithLifecycle() var shouldShowInputDialog by rememberSaveable { mutableStateOf(false) } @@ -63,7 +63,6 @@ fun DispenserScreen(onNavigateUp: () -> Unit, viewModel: DispenserViewModel = hi ScreenContent( dispensers = dispensers, - onNavigateUp = onNavigateUp, onAddDispenser = { shouldShowInputDialog = true }, onRemoveDispenser = { url -> shouldRemoveDispenser = url } ) @@ -71,7 +70,6 @@ fun DispenserScreen(onNavigateUp: () -> Unit, viewModel: DispenserViewModel = hi @Composable private fun ScreenContent( - onNavigateUp: () -> Unit = {}, dispensers: Set = emptySet(), onAddDispenser: () -> Unit = {}, onRemoveDispenser: (url: String) -> Unit = {} @@ -81,8 +79,7 @@ private fun ScreenContent( Scaffold( topBar = { TopAppBar( - title = stringResource(R.string.pref_dispenser_title), - onNavigateUp = onNavigateUp + title = stringResource(R.string.pref_dispenser_title) ) }, floatingActionButton = { @@ -97,11 +94,11 @@ private fun ScreenContent( } ) { paddingValues -> if (dispensers.isEmpty()) { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_server), message = stringResource(R.string.no_dispensers_available), - actionMessage = stringResource(R.string.add_dispenser_title), + actionLabel = stringResource(R.string.add_dispenser_title), onAction = onAddDispenser ) } else { @@ -109,7 +106,7 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)) + .padding(vertical = dimensionResource(R.dimen.spacing_medium)) ) { items(items = dispensers.toList(), key = { url -> url }) { url -> DispenserListItem( diff --git a/app/src/main/java/com/aurora/store/compose/ui/dispenser/InputDispenserDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/dispenser/InputDispenserDialog.kt index abfe565b9..c7db04883 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/dispenser/InputDispenserDialog.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/dispenser/InputDispenserDialog.kt @@ -53,7 +53,9 @@ fun InputDispenserDialog(onAdd: (url: String) -> Unit = {}, onDismiss: () -> Uni title = { Text(text = stringResource(R.string.add_dispenser_title)) }, text = { Column( - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { Text(text = stringResource(R.string.add_dispenser_summary)) OutlinedTextField( diff --git a/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt index 068c5fe09..38ababa77 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.PagingData @@ -42,13 +41,15 @@ import com.aurora.gplayapi.data.models.App import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator import com.aurora.store.compose.composable.DownloadListItem -import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.ui.downloads.menu.DownloadsMenu import com.aurora.store.compose.ui.downloads.menu.MenuItem +import com.aurora.store.compose.ui.sheets.DownloadActionsSheet import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.room.download.Download import com.aurora.store.viewmodel.downloads.DownloadsViewModel @@ -57,9 +58,8 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun DownloadsScreen( - onNavigateUp: () -> Unit, - viewModel: DownloadsViewModel = hiltViewModel(), - onNavigateToAppDetails: (packageName: String) -> Unit + onNavigateTo: (Destination) -> Unit, + viewModel: DownloadsViewModel = hiltViewModel() ) { val context = LocalContext.current val downloads = viewModel.downloads.collectAsLazyPagingItems() @@ -79,9 +79,8 @@ fun DownloadsScreen( ) ScreenContent( - onNavigateUp = onNavigateUp, downloads = downloads, - onNavigateToAppDetails = onNavigateToAppDetails, + onNavigateTo = onNavigateTo, onCancelAll = { viewModel.cancelAll() }, onForceClearAll = { viewModel.clearAll() }, onClearFinished = { viewModel.clearFinished() }, @@ -100,8 +99,7 @@ fun DownloadsScreen( @Composable private fun ScreenContent( downloads: LazyPagingItems = emptyPagingItems(), - onNavigateUp: () -> Unit = {}, - onNavigateToAppDetails: (packageName: String) -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {}, onCancel: (packageName: String) -> Unit = {}, onClear: (download: Download) -> Unit = {}, onExport: (download: Download) -> Unit = {}, @@ -117,6 +115,19 @@ private fun ScreenContent( * Save the initial loading state to make sure we don't replay the loading animation again. */ var initialLoad by rememberSaveable { mutableStateOf(true) } + var actionsTarget by rememberSaveable { mutableStateOf(null) } + + actionsTarget?.let { target -> + DownloadActionsSheet( + download = target, + onDismiss = { actionsTarget = null }, + onShowDetails = { onNavigateTo(Destination.AppDetails(target.packageName)) }, + onCancel = { onCancel(target.packageName) }, + onInstall = { onInstall(target) }, + onExport = { onExport(target) }, + onClear = { onClear(target) } + ) + } @Composable fun SetupMenu() { @@ -133,7 +144,6 @@ private fun ScreenContent( topBar = { TopAppBar( title = stringResource(R.string.title_download_manager), - onNavigateUp = onNavigateUp, actions = { if (downloads.itemCount != 0) SetupMenu() } ) } @@ -142,7 +152,7 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)) + .padding(vertical = dimensionResource(R.dimen.spacing_medium)) ) { when { downloads.loadState.refresh is LoadState.Loading && initialLoad -> { @@ -153,7 +163,7 @@ private fun ScreenContent( initialLoad = false if (downloads.itemCount == 0) { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_download_manager), message = stringResource(R.string.download_none) @@ -172,20 +182,13 @@ private fun ScreenContent( DownloadListItem( modifier = Modifier.animateItem(), download = download, - onClick = { - onNavigateToAppDetails(download.packageName) - }, - onClear = { onClear(download) }, - onCancel = { onCancel(download.packageName) }, - onExport = { onExport(download) }, - onInstall = { onInstall(download) } + onClick = { actionsTarget = download } ) } } } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt index 75306c346..e59ab371c 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.PagingData @@ -41,10 +40,11 @@ import com.aurora.extensions.emptyPagingItems import com.aurora.extensions.toast import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator -import com.aurora.store.compose.composable.Error import com.aurora.store.compose.composable.FavouriteListItem +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.FavouritePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.ui.favourite.menu.FavouriteMenu @@ -57,8 +57,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Composable fun FavouriteScreen( - onNavigateUp: () -> Unit, - onNavigateToAppDetails: (packageName: String) -> Unit, + onNavigateTo: (Destination) -> Unit, viewModel: FavouriteViewModel = hiltViewModel() ) { val context = LocalContext.current @@ -90,8 +89,7 @@ fun FavouriteScreen( ScreenContent( favourites = favourites, - onNavigateUp = onNavigateUp, - onNavigateToAppDetails = onNavigateToAppDetails, + onNavigateTo = onNavigateTo, onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) }, onImportFavourites = { documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE)) @@ -107,8 +105,7 @@ fun FavouriteScreen( @Composable private fun ScreenContent( favourites: LazyPagingItems = emptyPagingItems(), - onNavigateUp: () -> Unit = {}, - onNavigateToAppDetails: (packageName: String) -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {}, onRemoveFavourite: (packageName: String) -> Unit = {}, onImportFavourites: () -> Unit = {}, onExportFavourites: () -> Unit = {} @@ -135,7 +132,6 @@ private fun ScreenContent( topBar = { TopAppBar( title = stringResource(R.string.title_favourites_manager), - onNavigateUp = onNavigateUp, actions = { SetupMenu() } ) } @@ -144,7 +140,7 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)) + .padding(vertical = dimensionResource(R.dimen.spacing_medium)) ) { when { favourites.loadState.refresh is LoadState.Loading && initialLoad -> { @@ -155,7 +151,7 @@ private fun ScreenContent( initialLoad = false if (favourites.itemCount == 0) { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_favorite_unchecked), message = stringResource(R.string.details_no_favourites) @@ -175,7 +171,9 @@ private fun ScreenContent( modifier = Modifier.animateItem(), favourite = favourite, onClick = { - onNavigateToAppDetails(favourite.packageName) + onNavigateTo( + Destination.AppDetails(favourite.packageName) + ) }, onClear = { onRemoveFavourite(favourite.packageName) } ) @@ -184,7 +182,6 @@ private fun ScreenContent( } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt index 84e8396e3..86740e28d 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/installed/InstalledScreen.kt @@ -6,27 +6,32 @@ package com.aurora.store.compose.ui.installed +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.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -36,36 +41,48 @@ import com.aurora.extensions.emptyPagingItems import com.aurora.gplayapi.data.models.App import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator -import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.composable.TopAppBar -import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.composable.app.InstalledAppListItem +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.ui.commons.InstalledAppMeta +import com.aurora.store.compose.ui.commons.SortFilterSheet +import com.aurora.store.compose.ui.commons.SortFilterState import com.aurora.store.viewmodel.all.InstalledViewModel import kotlin.random.Random import kotlinx.coroutines.flow.MutableStateFlow @Composable fun InstalledScreen( - onNavigateUp: () -> Unit, - onNavigateToAppDetails: (packageName: String) -> Unit, + onNavigateTo: (Destination) -> Unit, viewModel: InstalledViewModel = hiltViewModel() ) { val apps = viewModel.apps.collectAsLazyPagingItems() + val state by viewModel.state.collectAsStateWithLifecycle() + val installers by viewModel.installers.collectAsStateWithLifecycle() + val metadata by viewModel.metadata.collectAsStateWithLifecycle() ScreenContent( apps = apps, - onNavigateUp = onNavigateUp, - onNavigateToAppDetails = onNavigateToAppDetails + state = state, + installers = installers, + metadata = metadata, + onStateChange = viewModel::updateState, + onNavigateTo = onNavigateTo ) } @Composable private fun ScreenContent( - onNavigateUp: () -> Unit = {}, apps: LazyPagingItems = emptyPagingItems(), - onNavigateToAppDetails: (packageName: String) -> Unit = {} + state: SortFilterState = SortFilterState(), + installers: Map = emptyMap(), + metadata: Map = emptyMap(), + onStateChange: (SortFilterState) -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {} ) { /* * For some reason paging3 frequently out-of-nowhere invalidates the list which causes @@ -74,12 +91,20 @@ private fun ScreenContent( * Save the initial loading state to make sure we don't replay the loading animation again. */ var initialLoad by rememberSaveable { mutableStateOf(true) } + var sheetVisible by remember { mutableStateOf(false) } Scaffold( topBar = { TopAppBar( title = stringResource(R.string.title_apps_games), - onNavigateUp = onNavigateUp + actions = { + IconButton(onClick = { sheetVisible = true }) { + Icon( + painter = painterResource(R.drawable.ic_tune), + contentDescription = stringResource(R.string.installed_sort_filter) + ) + } + } ) } ) { paddingValues -> @@ -97,7 +122,7 @@ private fun ScreenContent( initialLoad = false if (apps.itemCount == 0) { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_apps_outage), message = stringResource(R.string.no_apps_available) @@ -106,23 +131,34 @@ private fun ScreenContent( val listState = rememberLazyListState() Box(modifier = Modifier.fillMaxSize()) { LazyColumn( - state = listState + state = listState, + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { items( count = apps.itemCount, key = apps.itemKey { it.packageName } ) { index -> apps[index]?.let { app -> - LargeAppListItem( + val meta = metadata[app.packageName] + InstalledAppListItem( app = app, - onClick = { onNavigateToAppDetails(app.packageName) } + sizeBytes = meta?.sizeBytes ?: 0L, + lastUpdateTime = meta?.lastUpdateTime ?: 0L, + installerLabel = meta?.installer + ?.let { installers[it] }, + onClick = { + onNavigateTo( + Destination.AppDetails(app.packageName) + ) + } ) } } } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -131,6 +167,15 @@ private fun ScreenContent( } } } + + if (sheetVisible) { + SortFilterSheet( + state = state, + installers = installers, + onStateChange = onStateChange, + onDismiss = { sheetVisible = false } + ) + } } @PreviewWrapper(ThemePreviewProvider::class) diff --git a/app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt new file mode 100644 index 000000000..beea1e85a --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/main/MainScreen.kt @@ -0,0 +1,241 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.main + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.aurora.extensions.requiresObbDir +import com.aurora.store.MainViewModel +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.composition.LocalNetworkStatus +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.compose.ui.apps.AppsGamesScreen +import com.aurora.store.compose.ui.commons.MoreSheet +import com.aurora.store.compose.ui.commons.NetworkScreen +import com.aurora.store.compose.ui.sheets.AppUpdateSheet +import com.aurora.store.compose.ui.updates.UpdatesScreen +import com.aurora.store.data.model.NetworkStatus +import com.aurora.store.data.model.PermissionType +import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted +import com.aurora.store.data.room.update.Update +import com.aurora.store.viewmodel.all.UpdatesViewModel +import kotlinx.coroutines.launch + +private enum class MainTab( + @StringRes val labelRes: Int, + @DrawableRes val iconRes: Int +) { + APPS(R.string.title_apps, R.drawable.ic_apps), + GAMES(R.string.title_games, R.drawable.ic_games), + UPDATES(R.string.title_updates, R.drawable.ic_updates) +} + +@Composable +fun MainScreen( + initialTab: Int = 0, + mainViewModel: MainViewModel = hiltViewModel(), + updatesViewModel: UpdatesViewModel = hiltViewModel(), + onNavigateTo: (Destination) -> Unit = {} +) { + val context = LocalContext.current + val networkStatus = LocalNetworkStatus.current + val updates by mainViewModel.updateHelper.updates.collectAsStateWithLifecycle( + initialValue = null + ) + val updateCount = updates?.size ?: 0 + + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = initialTab.coerceIn( + 0, + MainTab.entries.size - 1 + ) + ) { + MainTab.entries.size + } + + var showMoreSheet by remember { mutableStateOf(false) } + var appUpdateTarget by remember { mutableStateOf(null) } + + fun handleNavigation(destination: Destination) { + when (destination) { + is Destination.AppUpdate -> appUpdateTarget = destination.update + else -> onNavigateTo(destination) + } + } + + if (networkStatus == NetworkStatus.UNAVAILABLE) { + NetworkScreen() + return + } + + if (showMoreSheet) { + MoreSheet( + onDismiss = { showMoreSheet = false }, + onNavigateTo = { destination -> + showMoreSheet = false + onNavigateTo(destination) + } + ) + } + + appUpdateTarget?.let { app -> + AppUpdateSheet( + update = app, + onDismiss = { appUpdateTarget = null }, + onNavigateTo = { destination -> + appUpdateTarget = null + onNavigateTo(destination) + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(MainTab.entries[pagerState.currentPage].labelRes), + actions = { + IconButton(onClick = { onNavigateTo(Destination.Downloads) }) { + Icon( + painter = painterResource(R.drawable.ic_download_manager), + contentDescription = stringResource(R.string.title_download_manager) + ) + } + IconButton(onClick = { showMoreSheet = true }) { + Icon( + painter = painterResource(R.drawable.ic_settings_account), + contentDescription = stringResource(R.string.title_more) + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { onNavigateTo(Destination.Search) }) { + Icon( + painter = painterResource(R.drawable.ic_round_search), + contentDescription = stringResource(R.string.action_search) + ) + } + }, + bottomBar = { + NavigationBar { + MainTab.entries.forEachIndexed { index, tab -> + NavigationBarItem( + selected = pagerState.currentPage == index, + onClick = { + coroutineScope.launch { pagerState.animateScrollToPage(index) } + }, + icon = { + if (tab == MainTab.UPDATES && updateCount > 0) { + BadgedBox(badge = { Badge { Text("$updateCount") } }) { + Icon( + painter = painterResource(tab.iconRes), + contentDescription = null + ) + } + } else { + Icon( + painter = painterResource(tab.iconRes), + contentDescription = null + ) + } + }, + label = { Text(stringResource(tab.labelRes)) } + ) + } + } + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + .fillMaxSize() + ) { + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondViewportPageCount = MainTab.entries.size - 1, + modifier = Modifier.fillMaxSize() + ) { page -> + when (MainTab.entries[page]) { + MainTab.APPS -> AppsGamesScreen( + pageType = 0, + onNavigateTo = onNavigateTo + ) + MainTab.GAMES -> AppsGamesScreen( + pageType = 1, + onNavigateTo = ::handleNavigation + ) + MainTab.UPDATES -> UpdatesScreen( + viewModel = updatesViewModel, + onNavigateTo = ::handleNavigation, + onRequestUpdate = { update -> + if (update.fileList.requiresObbDir() && + !isGranted(context, PermissionType.STORAGE_MANAGER) + ) { + onNavigateTo( + Destination.PermissionRationale( + setOf(PermissionType.STORAGE_MANAGER) + ) + ) + } else { + updatesViewModel.download(update) + } + }, + onRequestUpdateAll = { selectedUpdates -> + val needsObb = selectedUpdates.any { + it.fileList.requiresObbDir() + } + if (needsObb && !isGranted(context, PermissionType.STORAGE_MANAGER)) { + onNavigateTo( + Destination.PermissionRationale( + setOf(PermissionType.STORAGE_MANAGER) + ) + ) + } else { + updatesViewModel.downloadAll(selectedUpdates) + } + }, + onCancelUpdate = { packageName -> + updatesViewModel.cancelDownload(packageName) + }, + onCancelAll = { updatesViewModel.cancelAll() } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt index b0ff6d245..29fd8177e 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.aurora.extensions.toast import com.aurora.store.R @@ -62,12 +61,12 @@ private fun ScreenContent( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)), + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), state = listState ) { item { Column( - modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)) + modifier = Modifier.padding(dimensionResource(R.dimen.spacing_medium)) ) { Text( text = stringResource(R.string.onboarding_title_gsf), @@ -105,7 +104,6 @@ private fun ScreenContent( ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt index 2dc220178..53cf10dec 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt @@ -99,16 +99,16 @@ private fun ScreenContent( .fillMaxWidth() .navigationBarsPadding() .padding( - vertical = dimensionResource(R.dimen.padding_medium), + vertical = dimensionResource(R.dimen.spacing_medium), horizontal = if (windowAdaptiveInfo.isWindowCompact) { - dimensionResource(R.dimen.padding_medium) + dimensionResource(R.dimen.spacing_medium) } else { - dimensionResource(R.dimen.padding_xlarge) + dimensionResource(R.dimen.spacing_xlarge) } ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.padding_medium), + dimensionResource(R.dimen.spacing_medium), Alignment.End ) ) { diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/PermissionsPage.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/PermissionsPage.kt index 96386d853..21201774c 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/PermissionsPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/PermissionsPage.kt @@ -45,12 +45,12 @@ private fun PageContent( onPermissionCallback: (type: PermissionType) -> Unit = {} ) { PermissionList( - modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_medium)), + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.spacing_medium)), permissions = permissions, onPermissionCallback = onPermissionCallback, header = { Column( - modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)) + modifier = Modifier.padding(dimensionResource(R.dimen.spacing_medium)) ) { Text( text = stringResource(R.string.onboarding_title_permissions), diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/WelcomePage.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/WelcomePage.kt index de260c6da..0fa9d5fe6 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/WelcomePage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/WelcomePage.kt @@ -96,12 +96,12 @@ private fun PageContent(onAboutAurora: () -> Unit = {}) { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.padding_medium)), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall)) + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.spacing_medium)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) ) { stickyHeader { Column( - modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)) + modifier = Modifier.padding(dimensionResource(R.dimen.spacing_medium)) ) { Text( text = stringResource(R.string.onboarding_title_welcome), diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt new file mode 100644 index 000000000..a623e2a77 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/SettingsScreen.kt @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.data.model.PermissionType + +@Composable +fun SettingsScreen(onNavigateTo: (Destination) -> Unit) { + ScreenContent(onNavigateTo = onNavigateTo) +} + +@Composable +private fun ScreenContent(onNavigateTo: (Destination) -> Unit = {}) { + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.title_settings) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + item { + ListItem( + modifier = Modifier.clickable { + onNavigateTo( + Destination.PermissionRationale(PermissionType.entries.toSet()) + ) + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_list_check), + contentDescription = null + ) + }, + headlineContent = { + Text(stringResource(R.string.onboarding_title_permissions)) + } + ) + } + item { + ListItem( + modifier = Modifier.clickable { + onNavigateTo(Destination.InstallationPreference) + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_installation), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.title_installation)) } + ) + } + item { + ListItem( + modifier = Modifier.clickable { onNavigateTo(Destination.UIPreference) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_ui), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.pref_ui_title)) } + ) + } + item { + ListItem( + modifier = Modifier.clickable { onNavigateTo(Destination.NetworkPreference) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_network), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.pref_network_title)) } + ) + } + item { + ListItem( + modifier = Modifier.clickable { onNavigateTo(Destination.UpdatesPreference) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_updates), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.title_updates)) } + ) + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun SettingsScreenPreview() { + ScreenContent() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/UIPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/UIPreferenceScreen.kt new file mode 100644 index 000000000..872400775 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/UIPreferenceScreen.kt @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.core.net.toUri +import com.aurora.extensions.isTAndAbove +import com.aurora.extensions.setAppTheme +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_DEFAULT_SELECTED_TAB +import com.aurora.store.util.Preferences.PREFERENCE_FOR_YOU +import com.aurora.store.util.Preferences.PREFERENCE_THEME_STYLE +import com.aurora.store.util.save + +@Composable +fun UIPreferenceScreen() { + ScreenContent() +} + +@Composable +private fun ScreenContent() { + val context = LocalContext.current + + val themeEntries = stringArrayResource(R.array.pref_theme_style) + var themeStyle by remember { + mutableIntStateOf(Preferences.getInteger(context, PREFERENCE_THEME_STYLE)) + } + val tabEntries = stringArrayResource(R.array.pref_default_tab) + var selectedTab by remember { + mutableIntStateOf(Preferences.getInteger(context, PREFERENCE_DEFAULT_SELECTED_TAB)) + } + var forYou by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_FOR_YOU, true)) + } + var showThemeDialog by remember { mutableStateOf(false) } + var showTabDialog by remember { mutableStateOf(false) } + + if (showThemeDialog) { + SingleChoiceDialog( + title = stringResource(R.string.pref_ui_theme), + options = themeEntries.toList(), + selected = themeStyle, + onSelect = { index -> + themeStyle = index + context.save(PREFERENCE_THEME_STYLE, index) + setAppTheme(index) + showThemeDialog = false + }, + onDismiss = { showThemeDialog = false } + ) + } + + if (showTabDialog) { + SingleChoiceDialog( + title = stringResource(R.string.pref_ui_layout_tab), + options = tabEntries.toList(), + selected = selectedTab, + onSelect = { index -> + selectedTab = index + context.save(PREFERENCE_DEFAULT_SELECTED_TAB, index) + showTabDialog = false + }, + onDismiss = { showTabDialog = false } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.pref_ui_title) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + if (isTAndAbove) { + item { + ListItem( + modifier = Modifier.clickable { + context.startActivity( + Intent(Settings.ACTION_APP_LOCALE_SETTINGS).apply { + data = ("package:" + context.packageName).toUri() + } + ) + }, + headlineContent = { Text(stringResource(R.string.app_language)) }, + supportingContent = { Text(LocalLocale.current.platformLocale.displayName) } + ) + } + } + item { HorizontalDivider() } + item { + ListItem(headlineContent = { Text(stringResource(R.string.pref_ui_theme)) }) + } + item { + ListItem( + modifier = Modifier.clickable { showThemeDialog = true }, + headlineContent = { Text(stringResource(R.string.pref_ui_theme)) }, + supportingContent = { Text(themeEntries.getOrElse(themeStyle) { "" }) } + ) + } + item { HorizontalDivider() } + item { + ListItem(headlineContent = { Text(stringResource(R.string.pref_ui_layout)) }) + } + item { + ListItem( + modifier = Modifier.clickable { showTabDialog = true }, + headlineContent = { Text(stringResource(R.string.pref_ui_layout_tab)) }, + supportingContent = { Text(tabEntries.getOrElse(selectedTab) { "" }) } + ) + } + item { HorizontalDivider() } + item { + ListItem(headlineContent = { Text(stringResource(R.string.pref_common_extra)) }) + } + item { + ListItem( + modifier = Modifier.clickable { + forYou = !forYou + context.save(PREFERENCE_FOR_YOU, forYou) + }, + headlineContent = { Text(stringResource(R.string.pref_ui_no_for_you)) }, + supportingContent = { Text(stringResource(R.string.pref_ui_no_for_you_desc)) }, + trailingContent = { + Switch( + checked = forYou, + onCheckedChange = { checked -> + forYou = checked + context.save(PREFERENCE_FOR_YOU, checked) + } + ) + } + ) + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun UIPreferenceScreenPreview() { + ScreenContent() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallationPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallationPreferenceScreen.kt new file mode 100644 index 000000000..1023f45b1 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallationPreferenceScreen.kt @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences.installation + +import android.app.admin.DevicePolicyManager +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.core.content.getSystemService +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_AUTO_DELETE +import com.aurora.store.util.save + +@Composable +fun InstallationPreferenceScreen(onNavigateTo: (Destination) -> Unit) { + val context = LocalContext.current + val devicePolicyManager = context.getSystemService() + val isDeviceOwner = devicePolicyManager?.isDeviceOwnerApp(context.packageName) ?: false + + ScreenContent( + onNavigateTo = onNavigateTo, + isDeviceOwner = isDeviceOwner, + onClearDeviceOwner = { + @Suppress("DEPRECATION") + devicePolicyManager?.clearDeviceOwnerApp(context.packageName) + } + ) +} + +@Composable +private fun ScreenContent( + onNavigateTo: (Destination) -> Unit = {}, + isDeviceOwner: Boolean = false, + onClearDeviceOwner: () -> Unit = {} +) { + val context = LocalContext.current + var autoDelete by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_AUTO_DELETE, true)) + } + var showClearOwnerDialog by remember { mutableStateOf(false) } + + if (showClearOwnerDialog) { + AlertDialog( + onDismissRequest = { showClearOwnerDialog = false }, + title = { Text(stringResource(R.string.pref_clear_device_owner_title)) }, + text = { Text(stringResource(R.string.pref_clear_device_owner_desc)) }, + confirmButton = { + TextButton(onClick = { + showClearOwnerDialog = false + onClearDeviceOwner() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { showClearOwnerDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.title_installation) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + item { + ListItem( + modifier = Modifier.clickable { onNavigateTo(Destination.Installer) }, + headlineContent = { Text(stringResource(R.string.pref_install_mode_title)) }, + supportingContent = { Text(stringResource(R.string.pref_install_mode_summary)) } + ) + } + item { HorizontalDivider() } + item { + ListItem( + headlineContent = { Text(stringResource(R.string.pref_common_extra)) } + ) + } + item { + ListItem( + modifier = Modifier.clickable { + autoDelete = !autoDelete + context.save(PREFERENCE_AUTO_DELETE, autoDelete) + }, + headlineContent = { Text(stringResource(R.string.pref_install_delete_title)) }, + supportingContent = { + Text(stringResource(R.string.pref_install_delete_summary)) + }, + trailingContent = { + Switch( + checked = autoDelete, + onCheckedChange = { checked -> + autoDelete = checked + context.save(PREFERENCE_AUTO_DELETE, checked) + } + ) + } + ) + } + if (isDeviceOwner) { + item { + ListItem( + modifier = Modifier.clickable { showClearOwnerDialog = true }, + headlineContent = { + Text(stringResource(R.string.pref_clear_device_owner_title)) + }, + supportingContent = { + Text(stringResource(R.string.pref_clear_device_owner_summary)) + } + ) + } + } + } + } +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun InstallationPreferenceScreenPreview() { + ScreenContent() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt index 2292fd4db..09a9a367b 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/installation/InstallerScreen.kt @@ -35,7 +35,7 @@ import com.aurora.store.data.model.InstallerInfo import com.aurora.store.viewmodel.preferences.InstallerViewModel @Composable -fun InstallerScreen(onNavigateUp: () -> Unit, viewModel: InstallerViewModel = hiltViewModel()) { +fun InstallerScreen(viewModel: InstallerViewModel = hiltViewModel()) { val currentInstallerId by viewModel.currentInstaller.collectAsStateWithLifecycle() val snackBarHostState = remember { SnackbarHostState() } @@ -46,7 +46,6 @@ fun InstallerScreen(onNavigateUp: () -> Unit, viewModel: InstallerViewModel = hi } ScreenContent( - onNavigateUp = onNavigateUp, snackBarHostState = snackBarHostState, currentInstaller = Installer.entries[currentInstallerId], availableInstallers = AppInstaller.getAvailableInstallersInfo(LocalContext.current), @@ -56,7 +55,6 @@ fun InstallerScreen(onNavigateUp: () -> Unit, viewModel: InstallerViewModel = hi @Composable private fun ScreenContent( - onNavigateUp: () -> Unit = {}, snackBarHostState: SnackbarHostState = SnackbarHostState(), currentInstaller: Installer = Installer.SESSION, availableInstallers: List = emptyList(), @@ -70,8 +68,7 @@ private fun ScreenContent( }, topBar = { TopAppBar( - title = stringResource(R.string.pref_install_mode_title), - onNavigateUp = onNavigateUp + title = stringResource(R.string.pref_install_mode_title) ) } ) { paddingValues -> @@ -79,7 +76,7 @@ private fun ScreenContent( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)) + .padding(vertical = dimensionResource(R.dimen.spacing_medium)) ) { items(items = availableInstallers, key = { i -> i.id }) { installerInfo -> InstallerListItem( diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/network/NetworkPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/network/NetworkPreferenceScreen.kt new file mode 100644 index 000000000..9d5d4bfc1 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/network/NetworkPreferenceScreen.kt @@ -0,0 +1,316 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences.network + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.ui.commons.ForceRestartDialog +import com.aurora.store.util.CommonUtil +import com.aurora.store.util.PackageUtil +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_MICROG_AUTH +import com.aurora.store.util.Preferences.PREFERENCE_PROXY_INFO +import com.aurora.store.util.Preferences.PREFERENCE_PROXY_URL +import com.aurora.store.util.Preferences.PREFERENCE_VENDING_VERSION +import com.aurora.store.util.remove +import com.aurora.store.util.save +import com.aurora.store.viewmodel.preferences.ProxyURLViewModel +import com.jakewharton.processphoenix.ProcessPhoenix + +@Composable +fun NetworkPreferenceScreen( + onNavigateTo: (Destination) -> Unit, + viewModel: ProxyURLViewModel = hiltViewModel() +) { + val context = LocalContext.current + val hasMicroG = PackageUtil.hasSupportedMicroGVariant(context) + + ScreenContent( + onNavigateTo = onNavigateTo, + hasMicroG = hasMicroG, + onSaveProxyUrl = { url -> + val proxyInfo = CommonUtil.parseProxyUrl(url) + if (proxyInfo != null) { + context.save(PREFERENCE_PROXY_URL, url) + context.save(PREFERENCE_PROXY_INFO, viewModel.json.encodeToString(proxyInfo)) + true + } else { + false + } + }, + onDeleteProxyUrl = { + Preferences.remove(context, PREFERENCE_PROXY_URL) + Preferences.remove(context, PREFERENCE_PROXY_INFO) + } + ) +} + +@Composable +private fun ScreenContent( + onNavigateTo: (Destination) -> Unit = {}, + hasMicroG: Boolean = false, + onSaveProxyUrl: (String) -> Boolean = { false }, + onDeleteProxyUrl: () -> Unit = {} +) { + val context = LocalContext.current + + val vendingEntries = stringArrayResource(R.array.pref_vending_version) + var vendingVersion by remember { + mutableIntStateOf(Preferences.getInteger(context, PREFERENCE_VENDING_VERSION)) + } + var microGAuth by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_MICROG_AUTH, true)) + } + var showProxyDialog by remember { mutableStateOf(false) } + var showVendingDialog by remember { mutableStateOf(false) } + var showForceRestartDialog by remember { mutableStateOf(false) } + + if (showForceRestartDialog) { + ForceRestartDialog( + onConfirm = { ProcessPhoenix.triggerRebirth(context) }, + onDismiss = { showForceRestartDialog = false } + ) + } + + if (showProxyDialog) { + ProxyURLDialog( + currentUrl = Preferences.getString(context, PREFERENCE_PROXY_URL), + onSave = { url -> + showProxyDialog = false + if (onSaveProxyUrl(url)) { + showForceRestartDialog = true + } + }, + onDelete = { + showProxyDialog = false + onDeleteProxyUrl() + showForceRestartDialog = true + }, + onDismiss = { showProxyDialog = false } + ) + } + + if (showVendingDialog) { + SingleChoiceDialog( + title = stringResource(R.string.pref_vending_version_title), + options = vendingEntries.toList(), + selected = vendingVersion, + onSelect = { index -> + vendingVersion = index + context.save(PREFERENCE_VENDING_VERSION, index) + showVendingDialog = false + }, + onDismiss = { showVendingDialog = false } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.pref_network_title) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + item { + ListItem( + modifier = Modifier.clickable { onNavigateTo(Destination.Dispenser) }, + headlineContent = { Text(stringResource(R.string.pref_dispenser_title)) }, + supportingContent = { Text(stringResource(R.string.pref_dispenser_summary)) } + ) + } + item { + ListItem( + modifier = Modifier.clickable { showProxyDialog = true }, + headlineContent = { Text(stringResource(R.string.pref_network_proxy_url)) }, + supportingContent = { Text(stringResource(R.string.pref_network_proxy_desc)) } + ) + } + item { HorizontalDivider() } + item { + ListItem(headlineContent = { Text(stringResource(R.string.pref_common_extra)) }) + } + if (hasMicroG) { + item { + ListItem( + modifier = Modifier.clickable { + microGAuth = !microGAuth + context.save(PREFERENCE_MICROG_AUTH, microGAuth) + }, + headlineContent = { + Text(stringResource(R.string.pref_network_microg_login_title)) + }, + supportingContent = { + Text(stringResource(R.string.pref_network_microg_login_desc)) + }, + trailingContent = { + Switch( + checked = microGAuth, + onCheckedChange = { checked -> + microGAuth = checked + context.save(PREFERENCE_MICROG_AUTH, checked) + } + ) + } + ) + } + } + item { + ListItem( + modifier = Modifier.clickable { showVendingDialog = true }, + headlineContent = { Text(stringResource(R.string.pref_vending_version_title)) }, + supportingContent = { Text(vendingEntries.getOrElse(vendingVersion) { "" }) } + ) + } + } + } +} + +@Composable +private fun ProxyURLDialog( + currentUrl: String, + onSave: (String) -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit +) { + var url by remember { mutableStateOf(currentUrl) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.pref_network_proxy_url)) }, + text = { + Column { + Text( + text = stringResource(R.string.pref_network_proxy_url_message), + modifier = Modifier.padding(bottom = dimensionResource(R.dimen.spacing_small)) + ) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text(stringResource(R.string.pref_network_proxy_url_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + enabled = url.isNotBlank(), + onClick = { onSave(url.trim()) } + ) { + Text(stringResource(R.string.set)) + } + }, + dismissButton = { + Row { + if (currentUrl.isNotBlank()) { + TextButton(onClick = onDelete) { + Text(stringResource(R.string.disable)) + } + } + Spacer(Modifier.width(dimensionResource(R.dimen.spacing_small))) + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + } + ) +} + +@Composable +internal fun SingleChoiceDialog( + title: String, + options: List, + selected: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(modifier = Modifier.selectableGroup()) { + options.forEachIndexed { index, option -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = index == selected, + onClick = { onSelect(index) }, + role = Role.RadioButton + ) + .padding(vertical = dimensionResource(R.dimen.spacing_small)), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = index == selected, + onClick = null + ) + Spacer(Modifier.width(dimensionResource(R.dimen.spacing_small))) + Text(option) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun NetworkPreferenceScreenPreview() { + ScreenContent(hasMicroG = true) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/SourceFiltersScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/SourceFiltersScreen.kt new file mode 100644 index 000000000..9c9ac0458 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/SourceFiltersScreen.kt @@ -0,0 +1,210 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences.updates + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +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.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_FILTER_AURORA_ONLY +import com.aurora.store.util.Preferences.PREFERENCE_FILTER_INSTALLERS +import com.aurora.store.util.save +import com.aurora.store.viewmodel.all.UpdatesViewModel + +@Composable +fun SourceFiltersScreen(viewModel: UpdatesViewModel = hiltViewModel()) { + ScreenContent(onCheckUpdatesNow = { viewModel.updateHelper.checkUpdatesNow() }) +} + +@Composable +private fun ScreenContent(onCheckUpdatesNow: () -> Unit = {}) { + val context = LocalContext.current + + var auroraOnly by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_FILTER_AURORA_ONLY)) + } + var installers by remember { + mutableStateOf(Preferences.getStringSet(context, PREFERENCE_FILTER_INSTALLERS)) + } + var showAddDialog by remember { mutableStateOf(false) } + + // Trigger a fresh check on the way out so removed/added installers take effect + // without waiting for the next periodic run. + DisposableEffect(Unit) { + onDispose { onCheckUpdatesNow() } + } + + if (showAddDialog) { + AddInstallerDialog( + existing = installers, + onAdd = { name -> + installers = installers + name + context.save(PREFERENCE_FILTER_INSTALLERS, installers) + showAddDialog = false + }, + onDismiss = { showAddDialog = false } + ) + } + + Scaffold( + topBar = { TopAppBar(title = stringResource(R.string.pref_source_filters_title)) } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + item { + ListItem( + modifier = Modifier.clickable { + auroraOnly = !auroraOnly + context.save(PREFERENCE_FILTER_AURORA_ONLY, auroraOnly) + }, + headlineContent = { Text(stringResource(R.string.source_filters_all)) }, + supportingContent = { + Text(stringResource(R.string.pref_source_filters_desc_all)) + }, + trailingContent = { + Switch( + checked = auroraOnly, + onCheckedChange = { checked -> + auroraOnly = checked + context.save(PREFERENCE_FILTER_AURORA_ONLY, checked) + } + ) + } + ) + } + item { HorizontalDivider() } + item { + ListItem( + headlineContent = { + Text(stringResource(R.string.source_filters_installers_header)) + } + ) + } + if (installers.isEmpty()) { + item { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.source_filters_installers_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + } else { + items(items = installers.toList(), key = { it }) { name -> + ListItem( + headlineContent = { Text(name) }, + trailingContent = { + IconButton( + onClick = { + installers = installers - name + context.save(PREFERENCE_FILTER_INSTALLERS, installers) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_delete_forever), + contentDescription = stringResource( + R.string.source_filters_remove + ) + ) + } + } + ) + } + } + item { + ListItem( + modifier = Modifier.clickable { showAddDialog = true }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = null + ) + }, + headlineContent = { Text(stringResource(R.string.source_filters_add)) } + ) + } + } + } +} + +@Composable +private fun AddInstallerDialog( + existing: Set, + onAdd: (String) -> Unit, + onDismiss: () -> Unit +) { + var value by remember { mutableStateOf("") } + val trimmed = value.trim() + val canAdd = trimmed.isNotEmpty() && trimmed !in existing + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.source_filters_add)) }, + text = { + Column { + OutlinedTextField( + value = value, + label = { Text(stringResource(R.string.source_filters_add_dialog_hint)) }, + onValueChange = { value = it }, + singleLine = true + ) + } + }, + confirmButton = { + TextButton(onClick = { onAdd(trimmed) }, enabled = canAdd) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun SourceFiltersScreenPreview() { + ScreenContent() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/UpdatesPreferenceScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/UpdatesPreferenceScreen.kt new file mode 100644 index 000000000..9ff5b97cb --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/preferences/updates/UpdatesPreferenceScreen.kt @@ -0,0 +1,487 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.preferences.updates + +import android.Manifest +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.aurora.extensions.isIgnoringBatteryOptimizations +import com.aurora.extensions.isTAndAbove +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.ui.preferences.network.SingleChoiceDialog +import com.aurora.store.data.model.UpdateMode +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_BATTERY +import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_IDLE +import com.aurora.store.util.Preferences.PREFERENCES_UPDATES_RESTRICTIONS_METERED +import com.aurora.store.util.Preferences.PREFERENCE_FILTER_AURORA_ONLY +import com.aurora.store.util.Preferences.PREFERENCE_FILTER_FDROID +import com.aurora.store.util.Preferences.PREFERENCE_FILTER_INSTALLERS +import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_AUTO +import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_CHECK_INTERVAL +import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED +import com.aurora.store.util.save +import com.aurora.store.viewmodel.all.UpdatesViewModel +import kotlin.math.abs + +@Composable +fun UpdatesPreferenceScreen( + viewModel: UpdatesViewModel = hiltViewModel(), + onNavigateTo: (Destination) -> Unit = {} +) { + ScreenContent( + onCancelAutomatedCheck = { viewModel.updateHelper.cancelAutomatedCheck() }, + onScheduleAutomatedCheck = { viewModel.updateHelper.scheduleAutomatedCheck() }, + onUpdateAutomatedCheck = { viewModel.updateHelper.updateAutomatedCheck() }, + onCheckUpdatesNow = { viewModel.updateHelper.checkUpdatesNow() }, + onNavigateTo = onNavigateTo + ) +} + +@Composable +private fun ScreenContent( + onCancelAutomatedCheck: () -> Unit = {}, + onScheduleAutomatedCheck: () -> Unit = {}, + onUpdateAutomatedCheck: () -> Unit = {}, + onCheckUpdatesNow: () -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {} +) { + val context = LocalContext.current + val autoEntries = stringArrayResource(R.array.pref_updates_auto) + + var autoMode by remember { + mutableIntStateOf(Preferences.getInteger(context, PREFERENCE_UPDATES_AUTO, 2)) + } + val autoEnabled = autoMode != UpdateMode.DISABLED.ordinal + + var checkInterval by remember { + mutableIntStateOf(Preferences.getInteger(context, PREFERENCE_UPDATES_CHECK_INTERVAL, 3)) + } + val frequencyEntries = stringArrayResource(R.array.pref_updates_check_frequency_options) + var filterFDroid by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_FILTER_FDROID, true)) + } + var updatesExtended by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCE_UPDATES_EXTENDED)) + } + + // Re-read source-filter prefs after returning from SourceFiltersScreen. + val lifecycleOwner = LocalLifecycleOwner.current + var refreshKey by remember { mutableIntStateOf(0) } + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) refreshKey++ + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + val filterAuroraOnly = remember(refreshKey) { + Preferences.getBoolean(context, PREFERENCE_FILTER_AURORA_ONLY) + } + val installerCount = remember(refreshKey) { + Preferences.getStringSet(context, PREFERENCE_FILTER_INSTALLERS).size + } + var showAutoDialog by remember { mutableStateOf(false) } + var showFrequencyDialog by remember { mutableStateOf(false) } + var showRestrictionsDialog by remember { mutableStateOf(false) } + + val notifPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + context.save(PREFERENCE_UPDATES_AUTO, UpdateMode.CHECK_AND_NOTIFY.ordinal) + autoMode = UpdateMode.CHECK_AND_NOTIFY.ordinal + onScheduleAutomatedCheck() + } else { + autoMode = Preferences.getInteger(context, PREFERENCE_UPDATES_AUTO, 2) + } + } + + val batteryOptLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + if (context.isIgnoringBatteryOptimizations()) { + context.save(PREFERENCE_UPDATES_AUTO, UpdateMode.CHECK_AND_INSTALL.ordinal) + autoMode = UpdateMode.CHECK_AND_INSTALL.ordinal + onScheduleAutomatedCheck() + } else { + autoMode = Preferences.getInteger(context, PREFERENCE_UPDATES_AUTO, 2) + } + } + + if (showAutoDialog) { + SingleChoiceDialog( + title = stringResource(R.string.pref_updates_auto), + options = autoEntries.toList(), + selected = autoMode, + onSelect = { index -> + showAutoDialog = false + when (UpdateMode.entries[index]) { + UpdateMode.DISABLED -> { + context.save(PREFERENCE_UPDATES_AUTO, 0) + autoMode = 0 + onCancelAutomatedCheck() + } + UpdateMode.CHECK_AND_NOTIFY -> { + if (!isTAndAbove || + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + context.save(PREFERENCE_UPDATES_AUTO, 1) + autoMode = 1 + onScheduleAutomatedCheck() + } else { + notifPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + UpdateMode.CHECK_AND_INSTALL -> { + if (context.isIgnoringBatteryOptimizations()) { + context.save(PREFERENCE_UPDATES_AUTO, 2) + autoMode = 2 + onScheduleAutomatedCheck() + } else { + batteryOptLauncher.launch( + Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:${context.packageName}".toUri() + ) + ) + } + } + else -> {} + } + }, + onDismiss = { showAutoDialog = false } + ) + } + + if (showFrequencyDialog) { + SingleChoiceDialog( + title = stringResource(R.string.pref_updates_check_frequency), + options = frequencyEntries.toList(), + selected = selectedFrequencyIndex(checkInterval), + onSelect = { index -> + showFrequencyDialog = false + val hours = UPDATE_INTERVAL_HOURS[index] + checkInterval = hours + context.save(PREFERENCE_UPDATES_CHECK_INTERVAL, hours) + onUpdateAutomatedCheck() + }, + onDismiss = { showFrequencyDialog = false } + ) + } + + if (showRestrictionsDialog) { + UpdatesRestrictionsDialog( + onUpdateAutomatedCheck = onUpdateAutomatedCheck, + onDismiss = { showRestrictionsDialog = false } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.title_updates) + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + ) { + item { + ListItem( + modifier = Modifier.clickable { showAutoDialog = true }, + headlineContent = { Text(stringResource(R.string.pref_updates_auto)) }, + supportingContent = { Text(autoEntries.getOrElse(autoMode) { "" }) } + ) + } + if (autoEnabled) { + item { + ListItem( + modifier = Modifier.clickable { showFrequencyDialog = true }, + headlineContent = { + Text(stringResource(R.string.pref_updates_check_frequency)) + }, + supportingContent = { + Text(frequencyEntries[selectedFrequencyIndex(checkInterval)]) + } + ) + } + item { + ListItem( + modifier = Modifier.clickable { showRestrictionsDialog = true }, + headlineContent = { + Text(stringResource(R.string.pref_updates_restrictions_title)) + }, + supportingContent = { + Text(stringResource(R.string.pref_updates_restrictions_desc)) + } + ) + } + } + item { HorizontalDivider() } + item { + ListItem(headlineContent = { + Text(stringResource(R.string.pref_updates_app_source)) + }) + } + item { + ListItem( + modifier = Modifier.clickable { + onNavigateTo(Destination.SourceFilters) + }, + headlineContent = { + Text(stringResource(R.string.pref_source_filters_title)) + }, + supportingContent = { + Text(sourceFiltersSummary(filterAuroraOnly, installerCount)) + } + ) + } + item { HorizontalDivider() } + item { + ListItem(headlineContent = { Text(stringResource(R.string.pref_common_advanced)) }) + } + item { + ListItem( + modifier = Modifier.clickable { + filterFDroid = !filterFDroid + context.save(PREFERENCE_FILTER_FDROID, filterFDroid) + onCheckUpdatesNow() + }, + headlineContent = { Text(stringResource(R.string.pref_filter_fdroid_title)) }, + supportingContent = { + Text(stringResource(R.string.pref_filter_fdroid_summary)) + }, + trailingContent = { + Switch( + checked = filterFDroid, + onCheckedChange = { checked -> + filterFDroid = checked + context.save(PREFERENCE_FILTER_FDROID, checked) + onCheckUpdatesNow() + } + ) + } + ) + } + item { + ListItem( + modifier = Modifier.clickable { + updatesExtended = !updatesExtended + context.save(PREFERENCE_UPDATES_EXTENDED, updatesExtended) + onCheckUpdatesNow() + }, + headlineContent = { Text(stringResource(R.string.pref_updates_incompatible)) }, + supportingContent = { + Text(stringResource(R.string.pref_updates_incompatible_desc)) + }, + trailingContent = { + Switch( + checked = updatesExtended, + onCheckedChange = { checked -> + updatesExtended = checked + context.save(PREFERENCE_UPDATES_EXTENDED, checked) + onCheckUpdatesNow() + } + ) + } + ) + } + } + } +} + +@Composable +private fun UpdatesRestrictionsDialog(onUpdateAutomatedCheck: () -> Unit, onDismiss: () -> Unit) { + val context = LocalContext.current + var metered by remember { + mutableStateOf( + Preferences.getBoolean(context, PREFERENCES_UPDATES_RESTRICTIONS_METERED, true) + ) + } + var idle by remember { + mutableStateOf(Preferences.getBoolean(context, PREFERENCES_UPDATES_RESTRICTIONS_IDLE, true)) + } + var battery by remember { + mutableStateOf( + Preferences.getBoolean(context, PREFERENCES_UPDATES_RESTRICTIONS_BATTERY, true) + ) + } + + DisposableEffect(Unit) { + onDispose { onUpdateAutomatedCheck() } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.pref_updates_restrictions_title)) }, + text = { + Column { + Text(stringResource(R.string.pref_updates_restrictions_desc)) + Spacer(Modifier.height(dimensionResource(R.dimen.spacing_small))) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + metered = !metered + Preferences.putBoolean( + context, + PREFERENCES_UPDATES_RESTRICTIONS_METERED, + metered + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = metered, + onCheckedChange = { checked -> + metered = checked + Preferences.putBoolean( + context, + PREFERENCES_UPDATES_RESTRICTIONS_METERED, + checked + ) + } + ) + Text(stringResource(R.string.pref_updates_restrictions_metered)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + idle = !idle + Preferences.putBoolean( + context, + PREFERENCES_UPDATES_RESTRICTIONS_IDLE, + idle + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = idle, + onCheckedChange = { checked -> + idle = checked + Preferences.putBoolean( + context, + PREFERENCES_UPDATES_RESTRICTIONS_IDLE, + checked + ) + } + ) + Text(stringResource(R.string.pref_updates_restrictions_idle)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + battery = !battery + Preferences.putBoolean( + context, + PREFERENCES_UPDATES_RESTRICTIONS_BATTERY, + battery + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = battery, + onCheckedChange = { checked -> + battery = checked + Preferences.putBoolean( + context, + PREFERENCES_UPDATES_RESTRICTIONS_BATTERY, + checked + ) + } + ) + Text(stringResource(R.string.pref_updates_restrictions_battery)) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.ok)) + } + } + ) +} + +/** + * Discrete preset intervals (in hours) shown in the frequency chooser. Order matches + * `R.array.pref_updates_check_frequency_options`. + * + * Storage stays as a free-form `Int` hour count for backward compatibility with the + * old slider, which let users pick any value in 1..24. Existing values that don't + * match a preset are mapped to the nearest one for display via [selectedFrequencyIndex], + * but the stored value is left untouched until the user actively picks a new option. + */ +private val UPDATE_INTERVAL_HOURS = intArrayOf(3, 6, 12, 24, 72, 168, 360) + +private fun selectedFrequencyIndex(storedHours: Int): Int { + val exact = UPDATE_INTERVAL_HOURS.indexOf(storedHours) + if (exact >= 0) return exact + return UPDATE_INTERVAL_HOURS.indices.minBy { abs(UPDATE_INTERVAL_HOURS[it] - storedHours) } +} + +@Composable +private fun sourceFiltersSummary(allEnabled: Boolean, count: Int): String = when { + allEnabled -> stringResource(R.string.pref_source_filters_desc_all) + count == 0 -> stringResource(R.string.pref_source_filters_desc_none) + else -> stringResource(R.string.pref_source_filters_desc_count, count) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun UpdatesPreferenceScreenPreview() { + ScreenContent() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt index b20cf151b..9f8302ef5 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/search/SearchScreen.kt @@ -6,8 +6,9 @@ package com.aurora.store.compose.ui.search +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity import androidx.annotation.StringRes -import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Arrangement @@ -62,7 +63,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState @@ -74,10 +74,11 @@ import com.aurora.gplayapi.SearchSuggestEntry import com.aurora.gplayapi.data.models.App import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator -import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.Placeholder import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.composable.SearchSuggestionListItem import com.aurora.store.compose.composable.app.LargeAppListItem +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.ui.details.AppDetailsScreen @@ -91,7 +92,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @Composable -fun SearchScreen(onNavigateUp: () -> Unit, viewModel: SearchViewModel = hiltViewModel()) { +fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) { val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() val results = viewModel.apps.collectAsLazyPagingItems() @@ -102,7 +103,6 @@ fun SearchScreen(onNavigateUp: () -> Unit, viewModel: SearchViewModel = hiltView ScreenContent( suggestions = suggestions, results = results, - onNavigateUp = onNavigateUp, onSearch = onSearchCallback, onFetchSuggestions = onFetchSuggestionsCallback, onFilter = { filter -> viewModel.filterResults(filter) }, @@ -114,12 +114,12 @@ fun SearchScreen(onNavigateUp: () -> Unit, viewModel: SearchViewModel = hiltView private fun ScreenContent( suggestions: List = emptyList(), results: LazyPagingItems = emptyPagingItems(), - onNavigateUp: () -> Unit = {}, onFetchSuggestions: (String) -> Unit = {}, onSearch: (String) -> Unit = {}, onFilter: (filter: SearchFilter) -> Unit = {}, isAnonymous: Boolean = true ) { + val activity = LocalActivity.current as? ComponentActivity val textFieldState = rememberTextFieldState() val searchBarState = rememberSearchBarState() var isSearching by rememberSaveable { mutableStateOf(false) } @@ -186,7 +186,7 @@ private fun ScreenContent( ) }, leadingIcon = { - IconButton(onClick = onNavigateUp) { + IconButton(onClick = { activity?.onBackPressedDispatcher?.onBackPressed() }) { Icon( painter = painterResource(R.drawable.ic_arrow_back), contentDescription = stringResource(R.string.action_back) @@ -231,17 +231,14 @@ private fun ScreenContent( @Composable fun ListPane() { - // TODO: https://issuetracker.google.com/issues/445720462 Scaffold( - modifier = Modifier - .focusable(), topBar = { SearchBar() } ) { paddingValues -> Column( modifier = Modifier .padding(paddingValues) .fillMaxSize() - .padding(vertical = dimensionResource(R.dimen.padding_medium)) + .padding(vertical = dimensionResource(R.dimen.spacing_medium)) ) { FilterHeader( isEnabled = isSearching && results.loadState.refresh is LoadState.NotLoading, @@ -253,7 +250,7 @@ private fun ScreenContent( is LoadState.Loading -> ContainedLoadingIndicator() is LoadState.Error -> { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), message = stringResource(R.string.error) @@ -262,7 +259,7 @@ private fun ScreenContent( else -> { if (isSearching && results.itemCount == 0) { - Error( + Placeholder( modifier = Modifier.padding(paddingValues), painter = painterResource(R.drawable.ic_disclaimer), message = stringResource(R.string.no_apps_available) @@ -272,7 +269,10 @@ private fun ScreenContent( Box(modifier = Modifier.fillMaxSize()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) ) { items( count = results.itemCount, @@ -288,7 +288,6 @@ private fun ScreenContent( } ScrollHint( listState = listState, - bottomPadding = 5.dp, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -306,9 +305,10 @@ private fun ScreenContent( this != null -> { AppDetailsScreen( packageName = this, - onNavigateToAppDetails = { packageName -> showDetailPane(packageName) }, - onNavigateUp = { - coroutineScope.launch { scaffoldNavigator.navigateBack() } + onNavigateTo = { destination -> + if (destination is Destination.AppDetails) { + showDetailPane(destination.packageName) + } }, forceSinglePane = true ) @@ -316,7 +316,7 @@ private fun ScreenContent( else -> { if (isSearching && results.itemCount > 0) { - Error( + Placeholder( painter = painterResource(R.drawable.ic_round_search), message = stringResource(R.string.select_app_for_details) ) @@ -435,8 +435,8 @@ private fun FilterHeader( LazyRow( modifier = Modifier .fillMaxWidth() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)) + .padding(horizontal = dimensionResource(R.dimen.spacing_medium)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) ) { items(items = filters, key = { item -> item }) { filter -> val isSelected = when (filter) { diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt new file mode 100644 index 000000000..7c4642a4c --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/AppUpdateSheet.kt @@ -0,0 +1,245 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.sheets + +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.extensions.openInfo +import com.aurora.extensions.toast +import com.aurora.store.AuroraApp +import com.aurora.store.R +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.data.event.BusEvent +import com.aurora.store.data.installer.AppInstaller +import com.aurora.store.data.room.update.Update +import com.aurora.store.viewmodel.sheets.AppUpdateViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppUpdateSheet( + update: Update, + onDismiss: () -> Unit, + onNavigateTo: (Destination) -> Unit = {}, + viewModel: AppUpdateViewModel = hiltViewModel() +) { + val context = LocalContext.current + val isBlacklisted = viewModel.blacklistProvider.isBlacklisted(update.packageName) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + AppHeader( + update = update, + onShowDetails = { + onNavigateTo(Destination.AppDetails(update.packageName)) + onDismiss() + } + ) + + if (update.changelog.isNotEmpty()) { + ChangelogSection(html = update.changelog) + } + + HorizontalDivider( + modifier = Modifier.padding( + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) + + Item( + label = stringResource(R.string.action_ignore_all), + onClick = { + viewModel.ignoreAllUpdates(update.packageName) + onDismiss() + } + ) + + Item( + label = stringResource( + R.string.action_ignore_version, + update.versionName + ), + onClick = { + viewModel.ignoreVersion(update.packageName, update.versionCode) + onDismiss() + } + ) + + HorizontalDivider( + modifier = Modifier.padding( + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) + + Item( + label = stringResource( + if (isBlacklisted) { + R.string.action_whitelist + } else { + R.string.action_blacklist_add + } + ), + onClick = { + if (isBlacklisted) { + viewModel.blacklistProvider.whitelist(update.packageName) + context.toast(R.string.toast_apk_whitelisted) + } else { + viewModel.blacklistProvider.blacklist(update.packageName) + context.toast(R.string.toast_apk_blacklisted) + } + AuroraApp.events.send(BusEvent.Blacklisted(update.packageName)) + onDismiss() + } + ) + + Item( + label = stringResource(R.string.action_uninstall), + onClick = { + AppInstaller.uninstall(context, update.packageName) + onDismiss() + } + ) + + Item( + label = stringResource(R.string.action_info), + onClick = { + context.openInfo(update.packageName) + onDismiss() + } + ) + + Spacer(Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun AppHeader(update: Update, onShowDetails: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(update.iconURL) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = update.displayName, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = update.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource(R.string.version, update.versionName, update.versionCode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + FilledTonalButton(onClick = onShowDetails) { + Text(stringResource(R.string.updates_app_details)) + } + } +} + +@Composable +private fun Item(label: String, onClick: () -> Unit) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.spacing_xsmall)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) +} + +@Composable +private fun ChangelogSection(html: String) { + Text( + text = stringResource(R.string.details_changelog), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) + + Text( + text = AnnotatedString.fromHtml(html), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/DownloadActionsSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/DownloadActionsSheet.kt new file mode 100644 index 000000000..f5208a3ce --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/DownloadActionsSheet.kt @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.sheets + +import android.text.format.Formatter +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.aurora.store.R +import com.aurora.store.compose.composable.app.AnimatedAppIcon +import com.aurora.store.data.room.download.Download +import com.aurora.store.util.PackageUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DownloadActionsSheet( + download: Download, + onDismiss: () -> Unit, + onShowDetails: () -> Unit, + onCancel: () -> Unit, + onInstall: () -> Unit, + onExport: () -> Unit, + onClear: () -> Unit +) { + val context = LocalContext.current + val canInstall = download.canInstall(context) + val canExport = canInstall || PackageUtil.isInstalled(context, download.packageName) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + DownloadHeader( + download = download, + onShowDetails = { + onShowDetails() + onDismiss() + } + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = dimensionResource(R.dimen.spacing_xsmall)) + ) + + if (download.isRunning) { + Item( + label = stringResource(R.string.action_cancel), + enabled = download.isRunning, + onClick = { + onCancel() + onDismiss() + } + ) + } else { + Item( + label = stringResource(R.string.action_clear), + enabled = !download.isRunning, + onClick = { + onClear() + onDismiss() + } + ) + } + + if (!PackageUtil.isInstalled(context, download.packageName)) { + Item( + label = stringResource(R.string.action_install), + enabled = canInstall, + onClick = { + onInstall() + onDismiss() + } + ) + } + + Item( + label = stringResource(R.string.action_export), + enabled = canExport, + onClick = { + onExport() + onDismiss() + } + ) + + Spacer(Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun DownloadHeader(download: Download, onShowDetails: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) + ) { + AnimatedAppIcon( + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), + iconUrl = download.iconURL, + inProgress = download.isRunning, + progress = download.progress.toFloat() + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = download.displayName, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = download.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = Formatter.formatShortFileSize(LocalContext.current, download.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + FilledTonalButton(onClick = onShowDetails) { + Text(stringResource(R.string.updates_app_details)) + } + } +} + +@Composable +private fun Item(label: String, enabled: Boolean = true, onClick: () -> Unit) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.spacing_xsmall)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + .clickable(enabled = enabled, onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_small) + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt new file mode 100644 index 000000000..34a4dd87f --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/InstallErrorSheet.kt @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.sheets + +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.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.extensions.copyToClipBoard +import com.aurora.extensions.toast +import com.aurora.gplayapi.data.models.App +import com.aurora.store.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InstallErrorSheet(app: App, error: String?, extra: String?, onDismiss: () -> Unit) { + val context = LocalContext.current + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Header(app = app) + + HorizontalDivider( + modifier = Modifier.padding( + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) + + if (!error.isNullOrBlank()) { + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_xsmall), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = { + context.copyToClipBoard(listOfNotNull(error, extra).joinToString("\n\n")) + context.toast(R.string.toast_clipboard_copied) + } + ) { + Text(text = stringResource(R.string.action_copy)) + } + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.action_ok)) + } + } + + Spacer(Modifier.navigationBarsPadding()) + } + } +} + +@Composable +private fun Header(app: App) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.spacing_medium), + vertical = dimensionResource(R.dimen.spacing_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_medium)) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(app.iconArtwork.url) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.title_installer), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = app.displayName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/splash/SplashScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/splash/SplashScreen.kt new file mode 100644 index 000000000..388cac66e --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/splash/SplashScreen.kt @@ -0,0 +1,362 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.splash + +import android.accounts.Account +import android.accounts.AccountManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Base64 +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +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.fillMaxWidth +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.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE +import com.aurora.gplayapi.helpers.AuthHelper +import com.aurora.store.BuildConfig +import com.aurora.store.R +import com.aurora.store.compose.composition.LocalNetworkStatus +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.data.model.AuthState +import com.aurora.store.data.model.NetworkStatus +import com.aurora.store.util.CertUtil.GOOGLE_ACCOUNT_TYPE +import com.aurora.store.util.CertUtil.GOOGLE_PLAY_AUTH_TOKEN_TYPE +import com.aurora.store.util.CertUtil.GOOGLE_PLAY_CERT +import com.aurora.store.util.PackageUtil +import com.aurora.store.util.Preferences +import com.aurora.store.viewmodel.auth.AuthViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SplashScreen( + viewModel: AuthViewModel = hiltViewModel(), + deepLinkPackageName: String? = null, + deepLinkDevId: String? = null, + onNavigateTo: (Destination) -> Unit = {} +) { + val context = LocalContext.current + val activity = LocalActivity.current as? ComponentActivity + + val authState by viewModel.authState.collectAsStateWithLifecycle() + val isOnline = LocalNetworkStatus.current == NetworkStatus.AVAILABLE + + val canMicroGLogin = PackageUtil.hasSupportedMicroGVariant(context) && + Preferences.getBoolean(context, Preferences.PREFERENCE_MICROG_AUTH, true) + + var anonymousLoading by remember { mutableStateOf(false) } + var googleLoading by remember { mutableStateOf(false) } + + val accountLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val accountName = result.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) + if (!accountName.isNullOrBlank()) { + requestAuthTokenForGoogle( + viewModel = viewModel, + context = context, + accountName = accountName, + oldToken = null, + activity = activity, + onError = { googleLoading = false } + ) + } else { + googleLoading = false + } + } + + LaunchedEffect(authState) { + when (val state = authState) { + AuthState.Valid, AuthState.SignedIn -> { + anonymousLoading = false + googleLoading = false + when { + !deepLinkDevId.isNullOrBlank() -> onNavigateTo( + Destination.DevProfile(deepLinkDevId) + ) + !deepLinkPackageName.isNullOrBlank() -> onNavigateTo( + Destination.AppDetails(deepLinkPackageName) + ) + else -> onNavigateTo( + Destination.Main( + Preferences.getInteger( + context, + Preferences.PREFERENCE_DEFAULT_SELECTED_TAB + ) + ) + ) + } + } + is AuthState.PendingAccountManager -> requestAuthTokenForGoogle( + viewModel = viewModel, + context = context, + accountName = state.email, + oldToken = state.token, + activity = activity, + onError = { googleLoading = false } + ) + is AuthState.Failed -> { + anonymousLoading = false + googleLoading = false + } + else -> Unit + } + } + + LaunchedEffect(isOnline) { + if (isOnline && authState is AuthState.Failed) { + viewModel.retry() + } + } + + val isLoading = authState == AuthState.Init || + authState == AuthState.Available || + authState == AuthState.Fetching || + authState == AuthState.Verifying || + authState is AuthState.PendingAccountManager + + val showLoginButtons = authState is AuthState.Unavailable || + authState is AuthState.Failed || + authState is AuthState.SignedOut + + Scaffold( + topBar = { + TopAppBar( + title = {}, + actions = { + IconButton(onClick = { onNavigateTo(Destination.Settings) }) { + Icon( + painter = painterResource(R.drawable.ic_menu_settings), + contentDescription = stringResource(R.string.title_settings) + ) + } + IconButton(onClick = { onNavigateTo(Destination.About) }) { + Icon( + painter = painterResource(R.drawable.ic_menu_about), + contentDescription = stringResource(R.string.title_about) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .navigationBarsPadding() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.50f), + contentAlignment = Alignment.BottomCenter + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.ic_logo_alt), + contentDescription = null, + modifier = Modifier.size(dimensionResource(R.dimen.icon_size_splash)), + contentScale = ContentScale.FillBounds + ) + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.1f) + .padding(horizontal = dimensionResource(R.dimen.spacing_xlarge)), + contentAlignment = Alignment.Center + ) { + val (statusText, statusColor) = when { + !isOnline && showLoginButtons -> stringResource(R.string.title_no_network) to + MaterialTheme.colorScheme.error + authState is AuthState.Failed -> (authState as AuthState.Failed).status to + MaterialTheme.colorScheme.error + else -> statusText(authState) to MaterialTheme.colorScheme.onSurfaceVariant + } + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = statusColor, + textAlign = TextAlign.Center + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(0.40f), + contentAlignment = Alignment.TopCenter + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else if (showLoginButtons) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_small) + ) + ) { + Button( + modifier = Modifier.width(dimensionResource(R.dimen.width_button)), + enabled = !anonymousLoading && !googleLoading && isOnline, + onClick = { + if (canMicroGLogin) { + googleLoading = true + accountLauncher.launch( + AccountManager.newChooseAccountIntent( + null, + null, + arrayOf(GOOGLE_ACCOUNT_TYPE), + null, + null, + null, + null + ) + ) + } else { + onNavigateTo(Destination.GoogleLogin) + } + } + ) { + Text(stringResource(R.string.account_google)) + } + if (BuildConfig.SHOW_ANONYMOUS_LOGIN) { + OutlinedButton( + modifier = Modifier.width(dimensionResource(R.dimen.width_button)), + enabled = !googleLoading && !anonymousLoading && isOnline, + onClick = { + anonymousLoading = true + viewModel.buildAnonymousAuthData() + } + ) { + Text(stringResource(R.string.account_anonymous)) + } + } + if (googleLoading || anonymousLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + } + } + } + } + } +} + +@Composable +private fun statusText(authState: AuthState): String = when (authState) { + AuthState.Init -> stringResource(R.string.session_init) + AuthState.Available -> stringResource(R.string.session_verifying) + AuthState.Fetching -> stringResource(R.string.requesting_new_session) + AuthState.Verifying -> stringResource(R.string.verifying_new_session) + AuthState.Valid -> stringResource(R.string.session_verifying) + AuthState.SignedIn -> stringResource(R.string.session_good) + AuthState.Unavailable, AuthState.SignedOut -> stringResource(R.string.session_login) + is AuthState.PendingAccountManager -> stringResource(R.string.requesting_new_session) + is AuthState.Failed -> authState.status +} + +private fun requestAuthTokenForGoogle( + viewModel: AuthViewModel, + context: android.content.Context, + accountName: String, + oldToken: String?, + activity: ComponentActivity?, + onError: () -> Unit +) { + try { + val accountManager = AccountManager.get(context) + if (oldToken != null) { + accountManager.invalidateAuthToken(GOOGLE_ACCOUNT_TYPE, oldToken) + } + accountManager.getAuthToken( + Account(accountName, GOOGLE_ACCOUNT_TYPE), + GOOGLE_PLAY_AUTH_TOKEN_TYPE, + Bundle().apply { + putString("overridePackage", PACKAGE_NAME_PLAY_STORE) + putByteArray("overrideCertificate", Base64.decode(GOOGLE_PLAY_CERT, Base64.DEFAULT)) + }, + activity, + { result -> + val token = result.result.getString(AccountManager.KEY_AUTHTOKEN) + if (token != null) { + viewModel.buildGoogleAuthData(accountName, token, AuthHelper.Token.AUTH) + } else { + Log.e("SplashScreen", "AccountManager returned null auth token") + onError() + } + }, + Handler(Looper.getMainLooper()) + ) + } catch (_: Exception) { + Log.e("SplashScreen", "Failed to get authToken for Google login") + onError() + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/DevicePage.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/DevicePage.kt index 118920676..fc6c4a8b3 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/spoof/DevicePage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/DevicePage.kt @@ -53,7 +53,7 @@ private fun PageContent( ) { LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) ) { stickyHeader { Surface(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/LocalePage.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/LocalePage.kt index 83d8b0ece..9130e91d3 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/spoof/LocalePage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/LocalePage.kt @@ -52,7 +52,7 @@ private fun PageContent( ) { LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_xxsmall)) + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_xsmall)) ) { stickyHeader { Surface(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt index 4c2f1e392..f43b42fa2 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/spoof/SpoofScreen.kt @@ -36,6 +36,7 @@ import com.aurora.Constants import com.aurora.extensions.toast import com.aurora.store.R import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Destination import com.aurora.store.compose.ui.spoof.menu.MenuItem import com.aurora.store.compose.ui.spoof.menu.SpoofMenu import com.aurora.store.compose.ui.spoof.navigation.SpoofPage @@ -44,14 +45,9 @@ import com.aurora.store.viewmodel.spoof.SpoofViewModel import kotlinx.coroutines.launch @Composable -fun SpoofScreen( - onNavigateUp: () -> Unit, - onNavigateToSplash: () -> Unit, - viewModel: SpoofViewModel = hiltViewModel() -) { +fun SpoofScreen(onNavigateTo: (Destination) -> Unit, viewModel: SpoofViewModel = hiltViewModel()) { ScreenContent( - onNavigateUp = onNavigateUp, - onNavigateToSplash = onNavigateToSplash, + onNavigateTo = onNavigateTo, onDeviceSpoofImport = { uri -> viewModel.importDeviceSpoof(uri) }, onDeviceSpoofExport = { uri -> viewModel.exportDeviceSpoof(uri) } ) @@ -60,8 +56,7 @@ fun SpoofScreen( @Composable private fun ScreenContent( pages: List = listOf(SpoofPage.DEVICE, SpoofPage.LOCALE), - onNavigateUp: () -> Unit = {}, - onNavigateToSplash: () -> Unit = {}, + onNavigateTo: (Destination) -> Unit = {}, onDeviceSpoofImport: (uri: Uri) -> Unit = {}, onDeviceSpoofExport: (uri: Uri) -> Unit = {} ) { @@ -101,7 +96,7 @@ private fun ScreenContent( when (result) { SnackbarResult.ActionPerformed -> { AccountProvider.logout(context) - onNavigateToSplash() + onNavigateTo(Destination.Splash) } else -> Unit @@ -133,7 +128,6 @@ private fun ScreenContent( topBar = { TopAppBar( title = stringResource(R.string.title_spoof_manager), - onNavigateUp = onNavigateUp, actions = { SetupMenu() } ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt new file mode 100644 index 000000000..50e4f67d8 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/updates/UpdatesScreen.kt @@ -0,0 +1,255 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.updates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.aurora.store.R +import com.aurora.store.compose.composable.Placeholder +import com.aurora.store.compose.composable.SectionHeader +import com.aurora.store.compose.composable.ShimmerUpdateItem +import com.aurora.store.compose.composable.app.AppUpdateItem +import com.aurora.store.compose.navigation.Destination +import com.aurora.store.data.model.DownloadStatus +import com.aurora.store.data.room.download.Download +import com.aurora.store.data.room.update.Update +import com.aurora.store.viewmodel.all.UpdatesViewModel + +@Composable +fun UpdatesScreen( + viewModel: UpdatesViewModel = hiltViewModel(), + onNavigateTo: (Destination) -> Unit = {}, + onRequestUpdate: (Update) -> Unit = {}, + onRequestUpdateAll: (List) -> Unit = {}, + onCancelUpdate: (String) -> Unit = {}, + onCancelAll: () -> Unit = {} +) { + val context = LocalContext.current + val updates by viewModel.updates.collectAsStateWithLifecycle() + val ignoredUpdates by viewModel.ignoredUpdates.collectAsStateWithLifecycle() + val downloads by viewModel.downloadsList.collectAsStateWithLifecycle() + val fetchingUpdates by viewModel.fetchingUpdates.collectAsStateWithLifecycle() + + val updateMap = remember(updates, downloads) { + updates?.associateWith { update -> + downloads.find { + it.packageName == update.packageName && + it.versionCode == update.versionCode + } + } + } + + val groupedUpdates = remember(updateMap) { + val main = mutableListOf>() + val approval = mutableListOf>() + val incompatible = mutableListOf>() + updateMap?.entries?.forEach { entry -> + when { + entry.key.isIncompatible -> incompatible += entry + entry.key.requiresOwnershipTransfer(context) -> approval += entry + else -> main += entry + } + } + Triple(main.toList(), approval.toList(), incompatible.toList()) + } + val (mainEntries, approvalEntries, incompatibleEntries) = groupedUpdates + + val mainAnyActive = mainEntries.any { it.value.isActive() } + val approvalAnyActive = approvalEntries.any { it.value.isActive() } + + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + isRefreshing = fetchingUpdates, + onRefresh = { viewModel.fetchUpdates() } + ) { + when { + updateMap == null -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) + ) { + items(10) { ShimmerUpdateItem() } + } + } + + updateMap.isEmpty() && ignoredUpdates.isEmpty() -> { + Placeholder( + modifier = Modifier.fillMaxSize(), + painter = painterResource(R.drawable.ic_updates), + message = stringResource(R.string.details_no_updates), + actionLabel = stringResource(R.string.check_updates), + onAction = { viewModel.fetchUpdates() } + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.spacing_medium) + ) + ) { + if (mainEntries.isNotEmpty()) { + item(key = "header_main") { + val title = "${mainEntries.size} " + stringResource( + if (mainEntries.size == 1) { + R.string.update_available + } else { + R.string.updates_available + } + ) + val actionLabel = stringResource( + if (mainAnyActive) { + R.string.download_cancel_all + } else { + R.string.action_update_all + } + ) + SectionHeader( + title = title, + trailing = { + TextButton( + onClick = { + if (mainAnyActive) { + onCancelAll() + } else { + onRequestUpdateAll(mainEntries.map { it.key }) + } + } + ) { Text(actionLabel) } + } + ) + } + updateItems( + entries = mainEntries, + keyPrefix = "main", + onNavigateTo = onNavigateTo, + onRequestUpdate = onRequestUpdate, + onCancelUpdate = onCancelUpdate + ) + } + + if (approvalEntries.isNotEmpty()) { + item(key = "header_approval") { + val actionLabel = stringResource( + if (approvalAnyActive) { + R.string.download_cancel_all + } else { + R.string.action_update_all + } + ) + SectionHeader( + title = stringResource(R.string.updates_approval_header), + subtitle = stringResource(R.string.updates_approval_desc), + trailing = { + TextButton( + onClick = { + if (approvalAnyActive) { + onCancelAll() + } else { + onRequestUpdateAll( + approvalEntries.map { it.key } + ) + } + } + ) { Text(actionLabel) } + } + ) + } + updateItems( + entries = approvalEntries, + keyPrefix = "approval", + onNavigateTo = onNavigateTo, + onRequestUpdate = onRequestUpdate, + onCancelUpdate = onCancelUpdate + ) + } + + if (incompatibleEntries.isNotEmpty()) { + item(key = "header_incompatible") { + SectionHeader( + title = stringResource(R.string.updates_incompatible_header), + subtitle = stringResource(R.string.updates_incompatible_desc) + ) + } + updateItems( + entries = incompatibleEntries, + keyPrefix = "incompatible", + onNavigateTo = onNavigateTo, + onRequestUpdate = onRequestUpdate, + onCancelUpdate = onCancelUpdate + ) + } + + if (ignoredUpdates.isNotEmpty()) { + item(key = "header_ignored") { + SectionHeader( + title = stringResource(R.string.updates_ignored_header), + subtitle = stringResource(R.string.updates_ignored_desc) + ) + } + items( + items = ignoredUpdates, + key = { "ignored-${it.packageName}" } + ) { update -> + AppUpdateItem( + update = update, + onClick = {}, + onUnignore = { viewModel.unignore(update.packageName) } + ) + } + } + } + } + } + } +} + +private fun Download?.isActive(): Boolean { + if (this == null) return false + return !isFinished || status == DownloadStatus.COMPLETED +} + +private fun LazyListScope.updateItems( + entries: List>, + keyPrefix: String, + onNavigateTo: (Destination) -> Unit, + onRequestUpdate: (Update) -> Unit, + onCancelUpdate: (String) -> Unit +) { + items( + items = entries, + key = { "$keyPrefix-${it.key.packageName}" } + ) { (update, download) -> + AppUpdateItem( + update = update, + download = download, + onClick = { + onNavigateTo(Destination.AppUpdate(update)) + }, + onUpdate = { onRequestUpdate(update) }, + onCancel = { onCancelUpdate(update.packageName) } + ) + } +} diff --git a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt index b08f680e8..1fe336c11 100644 --- a/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/DownloadHelper.kt @@ -44,7 +44,12 @@ class DownloadHelper @Inject constructor( private const val VERSION_CODE = "VERSION_CODE" } - val downloadsList get() = downloadDao.downloads() + // Single stable StateFlow shared across consumers. Previously a `get()` accessor that + // returned a new StateFlow on every read, which made `collectAsStateWithLifecycle` re- + // collect on every recomposition and briefly replay the `emptyList()` initial value — + // causing the Updates screen's per-item button to flicker between "Update" and "Cancel" + // on every download tick. + val downloadsList = downloadDao.downloads() .stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), emptyList()) val pagedDownloads get() = downloadDao.pagedDownloads() @@ -160,9 +165,14 @@ class DownloadHelper @Inject constructor( * @param updatesOnly Whether to cancel only updates, defaults to false */ suspend fun cancelAll(updatesOnly: Boolean = false) { - // Cancel all enqueued downloads first to avoid triggering re-download + // Cancel queued/completed downloads first to avoid triggering re-download and to give + // the user immediate feedback for items that already finished downloading. The actual + // OS-level install for COMPLETED entries is wrapped in NonCancellable inside the + // worker and cannot be aborted — if it succeeds, the update row is removed via + // InstallerEvent.Installed; if it fails, the user can re-trigger an update. + val cancellableStatuses = setOf(DownloadStatus.QUEUED, DownloadStatus.COMPLETED) downloadDao.downloads().firstOrNull() - ?.filter { it.status == DownloadStatus.QUEUED } + ?.filter { it.status in cancellableStatuses } ?.filter { if (updatesOnly) it.isInstalled else true } ?.forEach { downloadDao.updateStatus(it.packageName, DownloadStatus.CANCELLED) diff --git a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt index 37a6c7fa0..bb7f27db1 100644 --- a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt @@ -17,6 +17,9 @@ import com.aurora.store.AuroraApp import com.aurora.store.data.event.BusEvent import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.model.UpdateMode +import com.aurora.store.data.room.update.IgnoredUpdate +import com.aurora.store.data.room.update.IgnoredUpdateDao +import com.aurora.store.data.room.update.Update import com.aurora.store.data.room.update.UpdateDao import com.aurora.store.data.work.UpdateWorker import com.aurora.store.util.Preferences @@ -30,6 +33,7 @@ import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.MINUTES import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -42,6 +46,7 @@ import kotlinx.coroutines.launch */ class UpdateHelper @Inject constructor( private val updateDao: UpdateDao, + private val ignoredUpdateDao: IgnoredUpdateDao, @ApplicationContext private val context: Context ) { @@ -86,9 +91,36 @@ class UpdateHelper @Inject constructor( private val isExtendedUpdateEnabled get() = Preferences.getBoolean(context, Preferences.PREFERENCE_UPDATES_EXTENDED) - val updates = updateDao.updates() + private val filteredUpdates = updateDao.updates() .map { list -> if (!isExtendedUpdateEnabled) list.filter { it.hasValidCert } else list } - .stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), null) + + /** + * Updates surfaced to the UI / notifications. Updates muted by the user via + * [ignoreAll] or [ignoreVersion] are filtered out — see [ignoredUpdates] for the + * complementary list. + */ + val updates = combine(filteredUpdates, ignoredUpdateDao.ignoredUpdates()) { list, ignored -> + val byPkg = ignored.associateBy { it.packageName } + list.filterNot { it.isIgnoredBy(byPkg[it.packageName]) } + }.stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), null) + + /** + * Updates that the user has chosen to ignore (all-future or version-specific) and + * which currently have a row in the `update` table. Used to render the "Ignored" + * footer section on the Updates screen. + */ + val ignoredUpdates = combine( + filteredUpdates, + ignoredUpdateDao.ignoredUpdates() + ) { list, ignored -> + val byPkg = ignored.associateBy { it.packageName } + list.filter { it.isIgnoredBy(byPkg[it.packageName]) } + }.stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), emptyList()) + + private fun Update.isIgnoredBy(rule: IgnoredUpdate?): Boolean { + if (rule == null) return false + return rule.ignoredVersionCode == null || rule.ignoredVersionCode == versionCode + } val isCheckingUpdates = WorkManager.getInstance(context) .getWorkInfosForUniqueWorkFlow(EXPEDITED_UPDATE_WORKER) @@ -153,6 +185,29 @@ class UpdateHelper @Inject constructor( updateDao.deleteAll() } + /** + * Hide all future updates for [packageName] until [unignore] is called. + */ + suspend fun ignoreAll(packageName: String) { + ignoredUpdateDao.upsert(IgnoredUpdate(packageName = packageName)) + } + + /** + * Hide only [versionCode] for [packageName]; newer versions show up again. + */ + suspend fun ignoreVersion(packageName: String, versionCode: Long) { + ignoredUpdateDao.upsert( + IgnoredUpdate(packageName = packageName, ignoredVersionCode = versionCode) + ) + } + + /** + * Drop the ignore rule for [packageName], regardless of which kind it was. + */ + suspend fun unignore(packageName: String) { + ignoredUpdateDao.delete(packageName) + } + /** * Cancels the automated updates check * @see [UpdateWorker] diff --git a/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt b/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt index ed31f2877..a57a1e051 100644 --- a/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt +++ b/app/src/main/java/com/aurora/store/data/model/BlacklistAppItem.kt @@ -6,12 +6,20 @@ package com.aurora.store.data.model import android.graphics.Bitmap +import com.aurora.store.compose.ui.commons.InstalledAppMeta data class BlacklistAppItem( - val packageName: String, + override val packageName: String, val displayName: String, val versionName: String, val versionCode: Long, val icon: Bitmap, - val isFiltered: Boolean -) + val isFiltered: Boolean, + override val firstInstallTime: Long = 0L, + override val lastUpdateTime: Long = 0L, + override val sizeBytes: Long = 0L, + override val isSystem: Boolean = false, + override val installer: String? = null +) : InstalledAppMeta { + override val label: String get() = displayName +} diff --git a/app/src/main/java/com/aurora/store/data/model/MinimalApp.kt b/app/src/main/java/com/aurora/store/data/model/MinimalApp.kt deleted file mode 100644 index 41519abef..000000000 --- a/app/src/main/java/com/aurora/store/data/model/MinimalApp.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.aurora.store.data.model - -import android.graphics.Bitmap -import android.os.Parcelable -import com.aurora.gplayapi.data.models.App -import com.aurora.store.data.room.update.Update -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Parcelize -data class MinimalApp( - val packageName: String, - val versionName: String, - val versionCode: Long, - val displayName: String, - @IgnoredOnParcel - val icon: Bitmap? = null -) : Parcelable { - - companion object { - - fun fromApp(app: App): MinimalApp = MinimalApp( - app.packageName, - app.versionName, - app.versionCode, - app.displayName - ) - - fun fromUpdate(update: Update): MinimalApp = MinimalApp( - update.packageName, - update.versionName, - update.versionCode, - update.displayName - ) - } -} diff --git a/app/src/main/java/com/aurora/store/data/providers/PermissionProvider.kt b/app/src/main/java/com/aurora/store/data/providers/PermissionProvider.kt index 9226be283..7c169b34d 100644 --- a/app/src/main/java/com/aurora/store/data/providers/PermissionProvider.kt +++ b/app/src/main/java/com/aurora/store/data/providers/PermissionProvider.kt @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package com.aurora.store.data.providers import android.Manifest diff --git a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt index 298ad090e..5bd70fda5 100644 --- a/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt +++ b/app/src/main/java/com/aurora/store/data/room/AuroraDatabase.kt @@ -8,12 +8,14 @@ import com.aurora.store.data.room.download.DownloadConverter import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.FavouriteDao +import com.aurora.store.data.room.update.IgnoredUpdate +import com.aurora.store.data.room.update.IgnoredUpdateDao import com.aurora.store.data.room.update.Update import com.aurora.store.data.room.update.UpdateDao @Database( - entities = [Download::class, Favourite::class, Update::class], - version = 6, + entities = [Download::class, Favourite::class, Update::class, IgnoredUpdate::class], + version = 8, exportSchema = true ) @TypeConverters(DownloadConverter::class) @@ -21,4 +23,5 @@ abstract class AuroraDatabase : RoomDatabase() { abstract fun downloadDao(): DownloadDao abstract fun favouriteDao(): FavouriteDao abstract fun updateDao(): UpdateDao + abstract fun ignoredUpdateDao(): IgnoredUpdateDao } diff --git a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt index fa2b05c90..67524dfd5 100644 --- a/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt +++ b/app/src/main/java/com/aurora/store/data/room/MigrationHelper.kt @@ -30,6 +30,14 @@ object MigrationHelper { override fun migrate(db: SupportSQLiteDatabase) = migrateFrom5To6(db) } + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) = migrateFrom6To7(db) + } + + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) = migrateFrom7To8(db) + } + private const val TAG = "MigrationHelper" private fun migrateFrom1To2(database: SupportSQLiteDatabase) { @@ -112,4 +120,43 @@ object MigrationHelper { database.endTransaction() } } + + /** + * Add isIncompatible column to update table for flagging updates that cannot be applied + * on the current OS (e.g. system app updates on HyperOS / GrapheneOS). + */ + private fun migrateFrom6To7(database: SupportSQLiteDatabase) { + database.beginTransaction() + try { + database.execSQL( + "ALTER TABLE `update` ADD COLUMN isIncompatible INTEGER NOT NULL DEFAULT 0" + ) + database.setTransactionSuccessful() + } catch (exception: Exception) { + Log.e(TAG, "Failed while migrating from database version 6 to 7", exception) + } finally { + database.endTransaction() + } + } + + /** + * Add ignored_update table for tracking updates that have been muted by the user. + * A null ignoredVersionCode means all future updates for this package are hidden. + */ + private fun migrateFrom7To8(database: SupportSQLiteDatabase) { + database.beginTransaction() + try { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `ignored_update` (" + + "`packageName` TEXT NOT NULL, " + + "`ignoredVersionCode` INTEGER, " + + "PRIMARY KEY(`packageName`))" + ) + database.setTransactionSuccessful() + } catch (exception: Exception) { + Log.e(TAG, "Failed while migrating from database version 7 to 8", exception) + } finally { + database.endTransaction() + } + } } diff --git a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt index 05ecb723e..a73329630 100644 --- a/app/src/main/java/com/aurora/store/data/room/RoomModule.kt +++ b/app/src/main/java/com/aurora/store/data/room/RoomModule.kt @@ -7,9 +7,12 @@ import com.aurora.store.data.room.MigrationHelper.MIGRATION_2_3 import com.aurora.store.data.room.MigrationHelper.MIGRATION_3_4 import com.aurora.store.data.room.MigrationHelper.MIGRATION_4_5 import com.aurora.store.data.room.MigrationHelper.MIGRATION_5_6 +import com.aurora.store.data.room.MigrationHelper.MIGRATION_6_7 +import com.aurora.store.data.room.MigrationHelper.MIGRATION_7_8 import com.aurora.store.data.room.download.DownloadConverter import com.aurora.store.data.room.download.DownloadDao import com.aurora.store.data.room.favourite.FavouriteDao +import com.aurora.store.data.room.update.IgnoredUpdateDao import com.aurora.store.data.room.update.UpdateDao import dagger.Module import dagger.Provides @@ -35,7 +38,9 @@ object RoomModule { MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, - MIGRATION_5_6 + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8 ) .addTypeConverter(downloadConverter) .build() @@ -50,4 +55,8 @@ object RoomModule { @Provides fun providesUpdateDao(auroraDatabase: AuroraDatabase): UpdateDao = auroraDatabase.updateDao() + + @Provides + fun providesIgnoredUpdateDao(auroraDatabase: AuroraDatabase): IgnoredUpdateDao = + auroraDatabase.ignoredUpdateDao() } diff --git a/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdate.kt b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdate.kt new file mode 100644 index 000000000..87a62662e --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdate.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.room.update + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Marks an update as ignored by the user. + * + * @property packageName Package whose updates are being ignored. + * @property ignoredVersionCode When `null`, all future updates for this package are hidden. + * When set, only that specific version is hidden — a newer version arriving will show up + * in the Updates list again. + */ +@Entity(tableName = "ignored_update") +data class IgnoredUpdate( + @PrimaryKey + val packageName: String, + val ignoredVersionCode: Long? = null +) diff --git a/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdateDao.kt b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdateDao.kt new file mode 100644 index 000000000..765a2c1b4 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/update/IgnoredUpdateDao.kt @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.room.update + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface IgnoredUpdateDao { + + @Query("SELECT * FROM ignored_update") + fun ignoredUpdates(): Flow> + + @Upsert + suspend fun upsert(ignoredUpdate: IgnoredUpdate) + + @Query("DELETE FROM ignored_update WHERE packageName = :packageName") + suspend fun delete(packageName: String) +} diff --git a/app/src/main/java/com/aurora/store/data/room/update/Update.kt b/app/src/main/java/com/aurora/store/data/room/update/Update.kt index bec3e8e76..fd5b9f38f 100644 --- a/app/src/main/java/com/aurora/store/data/room/update/Update.kt +++ b/app/src/main/java/com/aurora/store/data/room/update/Update.kt @@ -1,11 +1,15 @@ package com.aurora.store.data.room.update import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.os.Parcelable +import androidx.annotation.RequiresApi import androidx.room.Entity import androidx.room.PrimaryKey import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.PlayFile +import com.aurora.store.BuildConfig import com.aurora.store.data.room.download.SharedLib import com.aurora.store.util.CertUtil import com.aurora.store.util.PackageUtil @@ -29,11 +33,12 @@ data class Update( val offerType: Int, var fileList: List, val sharedLibs: List, - val targetSdk: Int = 1 + val targetSdk: Int = 1, + val isIncompatible: Boolean = false ) : Parcelable { companion object { - fun fromApp(context: Context, app: App): Update = Update( + fun fromApp(context: Context, app: App, isIncompatible: Boolean = false): Update = Update( app.packageName, app.versionCode, app.versionName, @@ -53,7 +58,8 @@ data class Update( app.offerType, app.fileList.filterNot { it.url.isBlank() }, app.dependencies.dependentLibraries.map { SharedLib.fromApp(it) }, - app.targetSdk + app.targetSdk, + isIncompatible ) } @@ -63,4 +69,27 @@ data class Update( fun isUpToDate(context: Context): Boolean = PackageUtil.isInstalled(context, packageName, versionCode) + + /** + * Update ownership was introduced in Android 14 (API 34) via + * [android.content.pm.PackageInstaller.SessionParams.setRequestUpdateOwnership]; if a + * different installer holds it, the OS shows a transfer-approval dialog at install time. + * Below API 34 the concept does not exist, so this returns false. We also return false when + * no installer has claimed ownership (`updateOwnerPackageName == null`) since no transfer + * prompt is shown in that case. + */ + fun requiresOwnershipTransfer(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return false + if (isSelfUpdate(context)) return false + return updateOwnerOnApi34(context.packageManager)?.let { + it != BuildConfig.APPLICATION_ID + } ?: false + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun updateOwnerOnApi34(packageManager: PackageManager): String? = try { + packageManager.getInstallSourceInfo(packageName).updateOwnerPackageName + } catch (_: PackageManager.NameNotFoundException) { + null + } } diff --git a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt index eec1c0775..1a22899d9 100644 --- a/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/DownloadWorker.kt @@ -116,10 +116,16 @@ class DownloadWorker @AssistedInject constructor( // Set work/service to foreground on < Android 12.0 setForeground(getForegroundInfo()) - // Try to purchase the app if file list is empty + // Try to purchase the app if file list is empty. Surface any GPlayApi error + // (e.g. AppNotPurchased, AppNotSupported, AppRemoved) as the failure cause so + // the user sees the real reason instead of a generic "files not available". notifyStatus(DownloadStatus.PURCHASING) - download.fileList = download.fileList.ifEmpty { - purchase(download.packageName, download.versionCode, download.offerType) + try { + download.fileList = download.fileList.ifEmpty { + purchase(download.packageName, download.versionCode, download.offerType) + } + } catch (exception: Exception) { + return onFailure(exception) } // Bail out if file list is empty after purchase @@ -137,20 +143,24 @@ class DownloadWorker @AssistedInject constructor( // Check if shared libs are present, if yes, handle them first if (download.sharedLibs.isNotEmpty()) { - download.sharedLibs.forEach { - // Create shared lib download dir - PathUtil.getLibDownloadDir( - context, - download.packageName, - download.versionCode, - it.packageName - ).mkdirs() + try { + download.sharedLibs.forEach { + // Create shared lib download dir + PathUtil.getLibDownloadDir( + context, + download.packageName, + download.versionCode, + it.packageName + ).mkdirs() - // Purchase shared lib if file list is empty - it.fileList = it.fileList.ifEmpty { - purchase(it.packageName, it.versionCode, 0) + // Purchase shared lib if file list is empty + it.fileList = it.fileList.ifEmpty { + purchase(it.packageName, it.versionCode, 0) + } + files.addAll(it.fileList) } - files.addAll(it.fileList) + } catch (exception: Exception) { + return onFailure(exception) } } files.addAll(download.fileList) @@ -227,13 +237,17 @@ class DownloadWorker @AssistedInject constructor( } else -> { - notifyStatus(status = DownloadStatus.FAILED, exception = exception) + val userMessage = exception.userReason() + ?: context.getString(R.string.download_failed) + notifyStatus( + status = DownloadStatus.FAILED, + message = userMessage + ) AuroraApp.events.send( InstallerEvent.Failed( packageName = download.packageName, - error = exception.stackTraceToString(), - extra = exception.message - ?: context.getString(R.string.download_failed) + error = userMessage, + extra = exception.stackTraceToString() ) ) } @@ -247,6 +261,22 @@ class DownloadWorker @AssistedInject constructor( } } + /** + * Extracts a user-readable message from an exception. Falls back to reading the + * `reason` property via reflection for GPlayApi's `InternalException` variants, + * which expose their detail through a data-class field rather than [Throwable.message]. + * + * TODO: Remove reflection and use a proper exception hierarchy once GPlayApi supports it. + */ + private fun Throwable.userReason(): String? { + message?.takeIf { it.isNotBlank() }?.let { return it } + return runCatching { + javaClass.getDeclaredField("reason") + .apply { isAccessible = true } + .get(this) as? String + }.getOrNull()?.takeIf { it.isNotBlank() } + } + /** * Purchases the app to get the download URL of the required files * @param packageName The packageName of the app @@ -255,21 +285,16 @@ class DownloadWorker @AssistedInject constructor( * @return A list of purchased files */ private fun purchase(packageName: String, versionCode: Long, offerType: Int): List { - try { - // Android 9.0+ supports key rotation, so purchase with latest certificate's hash - return if (isPAndAbove && PackageUtil.isInstalled(context, download.packageName)) { - purchaseHelper.purchase( - packageName, - versionCode, - offerType, - CertUtil.getEncodedCertificateHashes(context, download.packageName).last() - ) - } else { - purchaseHelper.purchase(packageName, versionCode, offerType) - } - } catch (exception: Exception) { - Log.e(TAG, "Failed to purchase $packageName", exception) - return emptyList() + // Android 9.0+ supports key rotation, so purchase with latest certificate's hash + return if (isPAndAbove && PackageUtil.isInstalled(context, download.packageName)) { + purchaseHelper.purchase( + packageName, + versionCode, + offerType, + CertUtil.getEncodedCertificateHashes(context, download.packageName).last() + ) + } else { + purchaseHelper.purchase(packageName, versionCode, offerType) } } @@ -400,7 +425,7 @@ class DownloadWorker @AssistedInject constructor( private suspend fun notifyStatus( status: DownloadStatus, isProgress: Boolean = false, - exception: Exception? = null + message: String? = null ) { // Update status in database download.status = status @@ -423,7 +448,7 @@ class DownloadWorker @AssistedInject constructor( context, download, icon, - exception?.message + message ) notificationManager.notify( if (isProgress) NOTIFICATION_ID else download.packageName.hashCode(), diff --git a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt index fbf66a286..21740d6ac 100644 --- a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt @@ -7,7 +7,6 @@ package com.aurora.store.data.work import android.app.NotificationManager import android.content.Context -import android.content.pm.PackageManager import android.net.Uri import android.util.Log import androidx.core.content.getSystemService @@ -20,10 +19,8 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters -import com.aurora.store.data.model.MinimalApp import com.aurora.store.data.room.download.Download import com.aurora.store.util.NotificationUtil -import com.aurora.store.util.PackageUtil.getPackageInfo import com.aurora.store.util.PathUtil import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -32,7 +29,7 @@ import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream /** - * An expedited worker to export downloaded or installed apps. + * An expedited worker to export downloaded apps to a user-chosen URI as a zip bundle. */ @HiltWorker class ExportWorker @AssistedInject constructor( @@ -44,7 +41,6 @@ class ExportWorker @AssistedInject constructor( private const val TAG = "ExportWorker" private const val URI = "URI" - private const val IS_DOWNLOAD = "IS_DOWNLOAD" private const val PACKAGE_NAME = "PACKAGE_NAME" private const val VERSION_CODE = "VERSION_CODE" private const val DISPLAY_NAME = "DISPLAY_NAME" @@ -52,28 +48,6 @@ class ExportWorker @AssistedInject constructor( private const val NOTIFICATION_ID = 500 private const val NOTIFICATION_ID_FGS = 501 - /** - * Exports the installed package to the given URI - * @param app App to export - * @see [ExportWorker] - */ - fun exportInstalledApp(context: Context, app: MinimalApp, uri: Uri) { - val inputData = Data.Builder() - .putBoolean(IS_DOWNLOAD, false) - .putString(URI, uri.toString()) - .putString(DISPLAY_NAME, app.displayName) - .putString(PACKAGE_NAME, app.packageName) - .build() - - val oneTimeWorkRequest = OneTimeWorkRequestBuilder() - .setInputData(inputData) - .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) - .build() - - Log.i(TAG, "Exporting ${app.packageName}") - WorkManager.getInstance(context).enqueue(oneTimeWorkRequest) - } - /** * Exports the given download to the URI * @param download Download to export @@ -81,7 +55,6 @@ class ExportWorker @AssistedInject constructor( */ fun exportDownloadedApp(context: Context, download: Download, uri: Uri) { val inputData = Data.Builder() - .putBoolean(IS_DOWNLOAD, download.canInstall(context)) .putString(URI, uri.toString()) .putString(DISPLAY_NAME, download.displayName) .putString(PACKAGE_NAME, download.packageName) @@ -101,7 +74,6 @@ class ExportWorker @AssistedInject constructor( private lateinit var notificationManager: NotificationManager override suspend fun doWork(): Result { - val isDownload = inputData.getBoolean(IS_DOWNLOAD, false) val uri = inputData.getString(URI)!!.toUri() val packageName = inputData.getString(PACKAGE_NAME) val displayName = inputData.getString(DISPLAY_NAME) @@ -109,18 +81,14 @@ class ExportWorker @AssistedInject constructor( notificationManager = context.getSystemService()!! - if (packageName.isNullOrEmpty() || isDownload && versionCode == -1L) { + if (packageName.isNullOrEmpty() || versionCode == -1L) { Log.e(TAG, "Input data is corrupt, bailing out!") notifyStatus(displayName ?: String(), uri, false) return Result.failure() } try { - if (isDownload) { - copyDownloadedApp(packageName, versionCode, uri) - } else { - copyInstalledApp(packageName, uri) - } + copyDownloadedApp(packageName, versionCode, uri) notifyStatus(displayName ?: packageName, uri) } catch (exception: Exception) { Log.e(TAG, "Failed to export $packageName", exception) @@ -148,18 +116,6 @@ class ExportWorker @AssistedInject constructor( ) } - private fun copyInstalledApp(packageName: String, uri: Uri) { - val packageInfo = getPackageInfo(context, packageName, PackageManager.GET_META_DATA) - val fileList: MutableList = mutableListOf() - - fileList.add(File(packageInfo.applicationInfo!!.sourceDir)) - packageInfo.applicationInfo!!.splitSourceDirs?.let { splits -> - fileList.addAll(splits.map { File(it) }) - } - - bundleAllAPKs(fileList.filterNotNull(), uri) - } - private fun copyDownloadedApp(packageName: String, versionCode: Long, uri: Uri) = bundleAllAPKs( PathUtil.getAppDownloadDir(context, packageName, versionCode).listFiles()!!.toList(), uri diff --git a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt index ff92e1166..f32a14527 100644 --- a/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/UpdateWorker.kt @@ -2,6 +2,7 @@ package com.aurora.store.data.work import android.app.NotificationManager import android.content.Context +import android.content.pm.ApplicationInfo import android.util.Log import androidx.core.content.getSystemService import androidx.hilt.work.HiltWorker @@ -9,6 +10,8 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import com.aurora.Constants import com.aurora.extensions.TAG +import com.aurora.extensions.isGrapheneOS +import com.aurora.extensions.isHyperOS import com.aurora.extensions.isIgnoringBatteryOptimizations import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.helpers.AppDetailsHelper @@ -99,10 +102,14 @@ class UpdateWorker @AssistedInject constructor( } try { - val updates = checkUpdates() + val allUpdates = checkUpdates() .also { updateDao.insertUpdates(it) } .filter { if (!isExtendedUpdateEnabled) it.hasValidCert else true } + // Incompatible updates (e.g. system app updates on HyperOS / GrapheneOS) are + // surfaced in the UI but excluded from notifications and auto-install. + val updates = allUpdates.filterNot { it.isIncompatible } + if (updates.isEmpty() || updateMode == UpdateMode.CHECK_ONLY) { Log.i(TAG, "Found ${updates.size} updates") return Result.success() @@ -159,18 +166,41 @@ class UpdateWorker @AssistedInject constructor( .filter { if (!isExtendedUpdateEnabled) it.applicationInfo!!.enabled else true } // Filter out packages based on user's preferences + val installerFilters = Preferences.getStringSet( + context, + Preferences.PREFERENCE_FILTER_INSTALLERS + ) val filteredPackages = if (isAuroraOnlyFilterEnabled) { packages.filter { CertUtil.isAuroraStoreApp(context, it.packageName) } } else { - packages.filterNot { - if (isFDroidFilterEnabled) { - CertUtil.isFDroidApp(context, it.packageName) + packages.filterNot { pkg -> + if (isFDroidFilterEnabled && CertUtil.isFDroidApp(context, pkg.packageName)) { + return@filterNot true + } + if (installerFilters.isNotEmpty()) { + val installer = PackageUtil.getInstallerPackageName( + context, + pkg.packageName + ) + installer != null && installer in installerFilters } else { false } } }.map { it.packageName } + // HyperOS and GrapheneOS block third-party updates of pristine system apps. + val osBlocksSystemAppUpdates = isHyperOS || isGrapheneOS + val pristineSystemPackages: Set = if (osBlocksSystemAppUpdates) { + packages.filter { + val flags = it.applicationInfo?.flags ?: 0 + flags and ApplicationInfo.FLAG_SYSTEM != 0 && + flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP == 0 + }.map { it.packageName }.toSet() + } else { + emptySet() + } + val updates = appDetailsHelper.getAppByPackageName(filteredPackages) .filter { it.displayName.isNotEmpty() } .filter { PackageUtil.isUpdatable(context, it.packageName, it.versionCode) } @@ -178,8 +208,13 @@ class UpdateWorker @AssistedInject constructor( if (canSelfUpdate) getSelfUpdate()?.let { updates.add(it) } - return@withContext updates.map { Update.fromApp(context, it) } - .sortedBy { it.displayName.lowercase(Locale.getDefault()) } + return@withContext updates.map { + Update.fromApp( + context, + it, + isIncompatible = it.packageName in pristineSystemPackages + ) + }.sortedBy { it.displayName.lowercase(Locale.getDefault()) } } } diff --git a/app/src/main/java/com/aurora/store/util/CertUtil.kt b/app/src/main/java/com/aurora/store/util/CertUtil.kt index 1db0d0669..8651cd732 100644 --- a/app/src/main/java/com/aurora/store/util/CertUtil.kt +++ b/app/src/main/java/com/aurora/store/util/CertUtil.kt @@ -83,10 +83,13 @@ object CertUtil { private fun isSignedByFDroid(context: Context, packageName: String): Boolean = try { getX509Certificates(context, packageName).any { cert -> - cert.subjectDN.name.split(",").associate { - val (left, right) = it.split("=") - left to right - }["O"] == "fdroid.org" + cert.subjectDN.name + .split(",") + .mapNotNull { + val parts = it.split("=", limit = 2) + if (parts.size == 2) parts[0] to parts[1] else null + } + .toMap()["O"] == "fdroid.org" } } catch (exception: Exception) { Log.e(TAG, "Failed to check signing cert for $packageName", exception) diff --git a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt index da2e95e64..99a6b9634 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -10,14 +10,13 @@ import android.graphics.Bitmap import android.graphics.Color import android.net.Uri import android.os.Build -import android.os.Bundle import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.getSystemService -import androidx.navigation.NavDeepLinkBuilder import com.aurora.Constants -import com.aurora.store.MainActivity +import com.aurora.store.ComposeActivity import com.aurora.store.R +import com.aurora.store.compose.navigation.Screen import com.aurora.store.data.activity.InstallActivity import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.installer.AppInstaller @@ -213,16 +212,7 @@ object NotificationUtil { .build() fun getUpdateNotification(context: Context, updatesList: List): Notification { - val arguments = Bundle().apply { - putInt("destinationId", R.id.updatesFragment) - } - - val contentIntent = NavDeepLinkBuilder(context) - .setGraph(R.navigation.mobile_navigation) - .setDestination(R.id.splashFragment) - .setComponentName(MainActivity::class.java) - .setArguments(arguments) - .createPendingIntent() + val contentIntent = getContentIntentForMain(context, initialTab = 2) return NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES) .setSmallIcon(R.drawable.ic_updates) @@ -297,7 +287,7 @@ object NotificationUtil { .setContentTitle(context.getString(R.string.authentication_required_title)) .setContentText(context.getString(R.string.authentication_required_unarchive)) .setAutoCancel(true) - .setContentIntent(getContentIntentForSplash(context, packageName)) + .setContentIntent(getContentIntentForDetails(context, packageName)) .setCategory(NotificationCompat.CATEGORY_ERROR) .setPriority(NotificationCompat.PRIORITY_HIGH) .build() @@ -359,28 +349,32 @@ object NotificationUtil { ) } - private fun getContentIntentForSplash(context: Context, packageName: String): PendingIntent { - val arguments = Bundle().apply { - putString("packageName", packageName) + private fun getContentIntentForDetails(context: Context, packageName: String): PendingIntent? { + val intent = Intent(context, ComposeActivity::class.java).apply { + putExtra(Screen.PARCEL_KEY, Screen.AppDetails(packageName) as android.os.Parcelable) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - return NavDeepLinkBuilder(context) - .setGraph(R.navigation.mobile_navigation) - .setDestination(R.id.splashFragment) - .setComponentName(MainActivity::class.java) - .setArguments(arguments) - .createPendingIntent() + return PendingIntentCompat.getActivity( + context, + packageName.hashCode().absoluteValue, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false + ) } - private fun getContentIntentForDetails(context: Context, packageName: String): PendingIntent { - val arguments = Bundle().apply { - putString("packageName", packageName) + private fun getContentIntentForMain(context: Context, initialTab: Int): PendingIntent? { + val intent = Intent(context, ComposeActivity::class.java).apply { + putExtra(Screen.PARCEL_KEY, Screen.Main(initialTab) as android.os.Parcelable) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } - return NavDeepLinkBuilder(context) - .setGraph(R.navigation.mobile_navigation) - .setDestination(R.id.splashFragment) - .setComponentName(MainActivity::class.java) - .setArguments(arguments) - .createPendingIntent() + return PendingIntentCompat.getActivity( + context, + initialTab, + intent, + PendingIntent.FLAG_UPDATE_CURRENT, + false + ) } private fun getInstallIntent(context: Context, download: Download): PendingIntent? { diff --git a/app/src/main/java/com/aurora/store/util/PackageUtil.kt b/app/src/main/java/com/aurora/store/util/PackageUtil.kt index 1f800c942..75ad1b36c 100644 --- a/app/src/main/java/com/aurora/store/util/PackageUtil.kt +++ b/app/src/main/java/com/aurora/store/util/PackageUtil.kt @@ -42,6 +42,7 @@ import com.aurora.Constants.PACKAGE_NAME_PLAY_STORE import com.aurora.extensions.isHuawei import com.aurora.extensions.isOAndAbove import com.aurora.extensions.isPAndAbove +import com.aurora.extensions.isRAndAbove import com.aurora.extensions.isTAndAbove import com.aurora.extensions.isVAndAbove import com.aurora.extensions.isValidApp @@ -270,6 +271,22 @@ object PackageUtil { } } + /** + * Returns the installer package name that installed [packageName], or null if unknown / not + * installed. Uses [PackageManager.getInstallSourceInfo] on API 30+; falls back to the + * pre-30 deprecated API below that. + */ + @Suppress("DEPRECATION") + fun getInstallerPackageName(context: Context, packageName: String): String? = try { + if (isRAndAbove) { + context.packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + context.packageManager.getInstallerPackageName(packageName) + } + } catch (_: PackageManager.NameNotFoundException) { + null + } + @Throws(Exception::class) fun getPackageInfo(context: Context, packageName: String, flags: Int = 0): PackageInfo = if (isTAndAbove) { diff --git a/app/src/main/java/com/aurora/store/util/Preferences.kt b/app/src/main/java/com/aurora/store/util/Preferences.kt index 73a9ec17f..5af10d467 100644 --- a/app/src/main/java/com/aurora/store/util/Preferences.kt +++ b/app/src/main/java/com/aurora/store/util/Preferences.kt @@ -39,6 +39,7 @@ object Preferences { const val PREFERENCE_FILTER_FDROID = "PREFERENCE_FILTER_FDROID" const val PREFERENCE_FILTER_AURORA_ONLY = "PREFERENCE_FILTER_AURORA_ONLY" + const val PREFERENCE_FILTER_INSTALLERS = "PREFERENCE_FILTER_INSTALLERS" const val PREFERENCE_AUTO_DELETE = "PREFERENCE_AUTO_DELETE" @@ -61,6 +62,16 @@ object Preferences { const val PREFERENCES_UPDATES_RESTRICTIONS_IDLE = "PREFERENCES_UPDATES_RESTRICTIONS_IDLE" const val PREFERENCES_UPDATES_RESTRICTIONS_BATTERY = "PREFERENCES_UPDATES_RESTRICTIONS_BATTERY" + const val PREFERENCE_INSTALLED_SORT_BY = "PREFERENCE_INSTALLED_SORT_BY" + const val PREFERENCE_INSTALLED_SORT_ORDER = "PREFERENCE_INSTALLED_SORT_ORDER" + const val PREFERENCE_INSTALLED_APP_TYPES = "PREFERENCE_INSTALLED_APP_TYPES" + const val PREFERENCE_INSTALLED_INSTALLER = "PREFERENCE_INSTALLED_INSTALLER" + + const val PREFERENCE_BLACKLIST_SORT_BY = "PREFERENCE_BLACKLIST_SORT_BY" + const val PREFERENCE_BLACKLIST_SORT_ORDER = "PREFERENCE_BLACKLIST_SORT_ORDER" + const val PREFERENCE_BLACKLIST_APP_TYPES = "PREFERENCE_BLACKLIST_APP_TYPES" + const val PREFERENCE_BLACKLIST_INSTALLER = "PREFERENCE_BLACKLIST_INSTALLER" + const val PREFERENCE_MIGRATION_VERSION = "PREFERENCE_MIGRATION_VERSION" private var prefs: SharedPreferences? = null diff --git a/app/src/main/java/com/aurora/store/view/custom/layouts/ActionHeaderLayout.kt b/app/src/main/java/com/aurora/store/view/custom/layouts/ActionHeaderLayout.kt deleted file mode 100644 index e43045fca..000000000 --- a/app/src/main/java/com/aurora/store/view/custom/layouts/ActionHeaderLayout.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.custom.layouts - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.widget.RelativeLayout -import com.aurora.store.R -import com.aurora.store.databinding.ViewActionHeaderBinding - -class ActionHeaderLayout : RelativeLayout { - - private lateinit var binding: ViewActionHeaderBinding - - constructor(context: Context) : super(context) { - init(context, null) - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - init(context, attrs) - } - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) { - init(context, attrs) - } - - private fun init(context: Context, attrs: AttributeSet?) { - val view = inflate(context, R.layout.view_action_header, this) - binding = ViewActionHeaderBinding.bind(view) - - val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ActionHeaderLayout) - val textTitle = typedArray.getString(R.styleable.ActionHeaderLayout_headerTitle) - val textSubtitle = typedArray.getString(R.styleable.ActionHeaderLayout_headerSubtitle) - - typedArray.recycle() - - textTitle?.let { - binding.txtTitle.text = it - } - - textSubtitle?.let { - binding.txtSubtitle.visibility = View.VISIBLE - binding.txtSubtitle.text = it - } - } - - fun setTitle(header: String?) { - binding.txtTitle.text = header - } - - fun setSubTitle(subHeader: String?) { - binding.txtSubtitle.visibility = View.VISIBLE - binding.txtSubtitle.text = subHeader - } - - fun addClickListener(onclickListener: OnClickListener?) { - binding.imgAction.visibility = View.VISIBLE - binding.imgAction.setOnClickListener(onclickListener) - } -} diff --git a/app/src/main/java/com/aurora/store/view/custom/layouts/button/StateButton.kt b/app/src/main/java/com/aurora/store/view/custom/layouts/button/StateButton.kt deleted file mode 100644 index 1fce7497a..000000000 --- a/app/src/main/java/com/aurora/store/view/custom/layouts/button/StateButton.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.custom.layouts.button - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.widget.RelativeLayout -import androidx.core.content.ContextCompat -import com.aurora.store.R -import com.aurora.store.databinding.ViewStateButtonBinding - -class StateButton : RelativeLayout { - - private lateinit var binding: ViewStateButtonBinding - - constructor(context: Context) : super(context) { - init(context, null) - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - init(context, attrs) - } - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) { - init(context, attrs) - } - - private fun init(context: Context, attrs: AttributeSet?) { - val view = inflate(context, R.layout.view_state_button, this) - binding = ViewStateButtonBinding.bind(view) - - val typedArray = context.obtainStyledAttributes(attrs, R.styleable.StateButton) - val btnTxt = typedArray.getString(R.styleable.StateButton_btnStateText) - val btnIcon = typedArray.getResourceId( - R.styleable.StateButton_btnStateIcon, - R.drawable.ic_arrow_right - ) - - binding.btn.text = btnTxt - binding.btn.icon = ContextCompat.getDrawable(context, btnIcon) - typedArray.recycle() - } - - fun updateProgress(isVisible: Boolean) { - if (isVisible) { - binding.progress.visibility = View.VISIBLE - } else { - binding.progress.visibility = View.INVISIBLE - } - } - - fun addOnClickListener(onClickListener: OnClickListener) { - binding.btn.setOnClickListener(onClickListener) - } -} diff --git a/app/src/main/java/com/aurora/store/view/custom/layouts/button/UpdateButton.kt b/app/src/main/java/com/aurora/store/view/custom/layouts/button/UpdateButton.kt deleted file mode 100644 index 54c195377..000000000 --- a/app/src/main/java/com/aurora/store/view/custom/layouts/button/UpdateButton.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.custom.layouts.button - -import android.content.Context -import android.util.AttributeSet -import android.widget.RelativeLayout -import com.aurora.extensions.runOnUiThread -import com.aurora.store.R -import com.aurora.store.data.model.DownloadStatus -import com.aurora.store.databinding.ViewUpdateButtonBinding - -class UpdateButton : RelativeLayout { - - private lateinit var binding: ViewUpdateButtonBinding - - constructor(context: Context) : super(context) { - init(context) - } - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { - init(context) - } - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) { - init(context) - } - - private fun init(context: Context) { - val view = inflate(context, R.layout.view_update_button, this) - binding = ViewUpdateButtonBinding.bind(view) - } - - fun updateState(downloadStatus: DownloadStatus) { - val displayChild = when (downloadStatus) { - DownloadStatus.QUEUED, - DownloadStatus.DOWNLOADING, - DownloadStatus.VERIFYING -> 1 - - else -> 0 - } - - if (binding.viewFlipper.displayedChild != displayChild) { - runOnUiThread { - binding.viewFlipper.displayedChild = displayChild - } - } - - // Not allowed to cancel installation at this point - binding.btnNegative.isEnabled = downloadStatus != DownloadStatus.VERIFYING - } - - fun addPositiveOnClickListener(onClickListener: OnClickListener?) { - binding.btnPositive.setOnClickListener(onClickListener) - } - - fun addNegativeOnClickListener(onClickListener: OnClickListener?) { - binding.btnNegative.setOnClickListener(onClickListener) - } -} diff --git a/app/src/main/java/com/aurora/store/view/custom/preference/AuroraListPreference.kt b/app/src/main/java/com/aurora/store/view/custom/preference/AuroraListPreference.kt deleted file mode 100644 index 58b249022..000000000 --- a/app/src/main/java/com/aurora/store/view/custom/preference/AuroraListPreference.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.custom.preference - -import android.content.Context -import android.util.AttributeSet -import androidx.preference.ListPreference - -class AuroraListPreference : ListPreference { - - constructor(context: Context) : super(context) - - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) - - constructor( - context: Context, - attrs: AttributeSet?, - defStyleAttr: Int, - defStyleRes: Int - ) : super(context, attrs, defStyleAttr, defStyleRes) - - override fun getPersistedString(defaultReturnValue: String?): String = - getPersistedInt(defaultReturnValue?.toInt() ?: -1).toString() - - override fun persistString(value: String?): Boolean = - if (value != null) persistInt(Integer.valueOf(value)) else false -} diff --git a/app/src/main/java/com/aurora/store/view/custom/preference/M3EditTextPreference.kt b/app/src/main/java/com/aurora/store/view/custom/preference/M3EditTextPreference.kt deleted file mode 100644 index 0e496b427..000000000 --- a/app/src/main/java/com/aurora/store/view/custom/preference/M3EditTextPreference.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.aurora.store.view.custom.preference - -import android.app.Dialog -import android.os.Bundle -import android.view.WindowManager -import androidx.preference.EditTextPreferenceDialogFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class M3EditTextPreference : EditTextPreferenceDialogFragmentCompat() { - - companion object { - const val PREFERENCE_DIALOG_FRAGMENT_TAG = "androidx.preference.PreferenceFragment.DIALOG" - - fun newInstance(key: String?): M3EditTextPreference { - val fragment = M3EditTextPreference() - val bundle = Bundle(1) - bundle.putString(ARG_KEY, key) - fragment.arguments = bundle - return fragment - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - .setTitle(preference.dialogTitle) - .setIcon(preference.dialogIcon) - .setPositiveButton(preference.positiveButtonText, this) - .setNegativeButton(preference.negativeButtonText, this) - - val contentView = onCreateDialogView(requireContext()) - if (contentView != null) { - onBindDialogView(contentView) - builder.setView(contentView) - } else { - builder.setMessage(preference.dialogMessage) - } - - onPrepareDialogBuilder(builder) - return builder.create() - } - - override fun onResume() { - super.onResume() - dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - } -} diff --git a/app/src/main/java/com/aurora/store/view/custom/preference/M3ListPreference.kt b/app/src/main/java/com/aurora/store/view/custom/preference/M3ListPreference.kt deleted file mode 100644 index 92790b434..000000000 --- a/app/src/main/java/com/aurora/store/view/custom/preference/M3ListPreference.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.aurora.store.view.custom.preference - -import android.app.Dialog -import android.os.Bundle -import androidx.preference.ListPreferenceDialogFragmentCompat -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class M3ListPreference : ListPreferenceDialogFragmentCompat() { - - companion object { - const val PREFERENCE_DIALOG_FRAGMENT_TAG = "androidx.preference.PreferenceFragment.DIALOG" - - fun newInstance(key: String?): M3ListPreference { - val fragment = M3ListPreference() - val bundle = Bundle(1) - bundle.putString(ARG_KEY, key) - fragment.arguments = bundle - return fragment - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - .setTitle(preference.dialogTitle) - .setIcon(preference.dialogIcon) - .setPositiveButton(preference.positiveButtonText, this) - .setNegativeButton(preference.negativeButtonText, this) - - val contentView = onCreateDialogView(requireContext()) - if (contentView != null) { - onBindDialogView(contentView) - builder.setView(contentView) - } else { - builder.setMessage(preference.dialogMessage) - } - - onPrepareDialogBuilder(builder) - return builder.create() - } -} diff --git a/app/src/main/java/com/aurora/store/view/custom/recycler/EndlessRecyclerOnScrollListener.kt b/app/src/main/java/com/aurora/store/view/custom/recycler/EndlessRecyclerOnScrollListener.kt deleted file mode 100644 index 5e0dea093..000000000 --- a/app/src/main/java/com/aurora/store/view/custom/recycler/EndlessRecyclerOnScrollListener.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.custom.recycler - -import android.view.View -import androidx.recyclerview.widget.OrientationHelper -import androidx.recyclerview.widget.RecyclerView - -abstract class EndlessRecyclerOnScrollListener : RecyclerView.OnScrollListener { - - private var enabled = true - private var previousTotal = 0 - private var isLoading = true - private var visibleThreshold = RecyclerView.NO_POSITION - - var firstVisibleItem: Int = 0 - private set - var visibleItemCount: Int = 0 - private set - var totalItemCount: Int = 0 - private set - - private var isOrientationHelperVertical: Boolean = false - private var orientationHelper: OrientationHelper? = null - - var currentPage = 0 - private set - - lateinit var layoutManager: RecyclerView.LayoutManager - private set - - constructor() - - constructor(visibleThreshold: Int) { - this.visibleThreshold = visibleThreshold - } - - private fun findFirstVisibleItemPosition(recyclerView: RecyclerView): Int { - val child = findOneVisibleChild(0, layoutManager.childCount, true, false) - return if (child == null) { - RecyclerView.NO_POSITION - } else { - recyclerView.getChildAdapterPosition(child) - } - } - - private fun findLastVisibleItemPosition(recyclerView: RecyclerView): Int { - val child = findOneVisibleChild(recyclerView.childCount - 1, -1, false, true) - return if (child == null) { - RecyclerView.NO_POSITION - } else { - recyclerView.getChildAdapterPosition(child) - } - } - - private fun findOneVisibleChild( - fromIndex: Int, - toIndex: Int, - completelyVisible: Boolean, - acceptPartiallyVisible: Boolean - ): View? { - if (layoutManager.canScrollVertically() != isOrientationHelperVertical || - orientationHelper == null - ) { - isOrientationHelperVertical = layoutManager.canScrollVertically() - orientationHelper = if (isOrientationHelperVertical) { - OrientationHelper.createVerticalHelper(layoutManager) - } else { - OrientationHelper.createHorizontalHelper(layoutManager) - } - } - - val mOrientationHelper = this.orientationHelper ?: return null - - val start = mOrientationHelper.startAfterPadding - val end = mOrientationHelper.endAfterPadding - val next = if (toIndex > fromIndex) 1 else -1 - var partiallyVisible: View? = null - var i = fromIndex - while (i != toIndex) { - val child = layoutManager.getChildAt(i) - if (child != null) { - val childStart = mOrientationHelper.getDecoratedStart(child) - val childEnd = mOrientationHelper.getDecoratedEnd(child) - if (childStart < end && childEnd > start) { - if (completelyVisible) { - if (childStart >= start && childEnd <= end) { - return child - } else if (acceptPartiallyVisible && partiallyVisible == null) { - partiallyVisible = child - } - } else { - return child - } - } - } - i += next - } - return partiallyVisible - } - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - - if (enabled) { - if (!::layoutManager.isInitialized) { - layoutManager = recyclerView.layoutManager - ?: throw RuntimeException("A LayoutManager is required") - } - - if (visibleThreshold == RecyclerView.NO_POSITION) { - visibleThreshold = findLastVisibleItemPosition(recyclerView) - - findFirstVisibleItemPosition(recyclerView) - } - - visibleItemCount = recyclerView.childCount - totalItemCount = layoutManager.itemCount - firstVisibleItem = findFirstVisibleItemPosition(recyclerView) - - if (isLoading) { - if (totalItemCount > previousTotal) { - isLoading = false - previousTotal = totalItemCount - } - } - - if (!isLoading && - totalItemCount - visibleItemCount <= firstVisibleItem + visibleThreshold - ) { - currentPage++ - onLoadMore(currentPage) - isLoading = true - } - } - } - - fun enable(): EndlessRecyclerOnScrollListener { - enabled = true - return this - } - - fun disable(): EndlessRecyclerOnScrollListener { - enabled = false - return this - } - - @JvmOverloads - fun resetPageCount(page: Int = 0) { - previousTotal = 0 - isLoading = true - currentPage = page - onLoadMore(currentPage) - } - - abstract fun onLoadMore(currentPage: Int) -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/controller/CategoryCarouselController.kt b/app/src/main/java/com/aurora/store/view/epoxy/controller/CategoryCarouselController.kt deleted file mode 100644 index 0975afd97..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/controller/CategoryCarouselController.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.controller - -import com.aurora.gplayapi.data.models.StreamCluster - -class CategoryCarouselController(callbacks: Callbacks) : GenericCarouselController(callbacks) { - - override fun applyFilter(streamBundle: StreamCluster): Boolean { - return streamBundle.clusterAppList.isNotEmpty() // Filter empty clusters - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/controller/GenericCarouselController.kt b/app/src/main/java/com/aurora/store/view/epoxy/controller/GenericCarouselController.kt deleted file mode 100644 index f9d7ccb16..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/controller/GenericCarouselController.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.controller - -import com.airbnb.epoxy.TypedEpoxyController -import com.aurora.gplayapi.data.models.App -import com.aurora.gplayapi.data.models.StreamBundle -import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.store.R -import com.aurora.store.view.epoxy.groups.CarouselModelGroup -import com.aurora.store.view.epoxy.groups.CarouselShimmerGroup -import com.aurora.store.view.epoxy.views.app.AppListViewModel_ -import com.aurora.store.view.epoxy.views.app.NoAppViewModel_ - -open class GenericCarouselController(private val callbacks: Callbacks) : - - TypedEpoxyController() { - - interface Callbacks { - fun onHeaderClicked(streamCluster: StreamCluster) - fun onClusterScrolled(streamCluster: StreamCluster) - fun onAppClick(app: App) - fun onAppLongClick(app: App) - } - - open fun applyFilter(streamBundle: StreamCluster): Boolean { - return streamBundle.clusterTitle.isNotBlank() && - // Filter noisy cluster - streamBundle.clusterAppList.isNotEmpty() && - // Filter empty clusters - streamBundle.clusterAppList.count() > 1 // Filter clusters with single apps (promotions) - } - - override fun buildModels(streamBundle: StreamBundle?) { - setFilterDuplicates(true) - if (streamBundle == null) { - for (i in 1..4) { - add( - CarouselShimmerGroup() - .id(i) - ) - } - } else { - if (streamBundle.streamClusters.isEmpty()) { - add( - NoAppViewModel_() - .id("no_app") - .icon(R.drawable.ic_apps) - .message(R.string.no_apps_available) - ) - } else { - if (streamBundle.streamClusters.size == 1) { - streamBundle - .streamClusters - .values - .filter { applyFilter(it) } - .forEach { streamCluster -> - streamCluster.clusterAppList.forEach { - add( - AppListViewModel_() - .id(it.id) - .app(it) - .click { _ -> callbacks.onAppClick(it) } - ) - } - } - } else { - streamBundle - .streamClusters - .values - .filter { applyFilter(it) } - .forEach { streamCluster -> - add(CarouselModelGroup(streamCluster, callbacks)) - } - } - if (streamBundle.hasNext()) { - add( - CarouselShimmerGroup() - .id("progress") - ) - } - } - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselHorizontal.kt b/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselHorizontal.kt deleted file mode 100644 index 8a57ae393..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselHorizontal.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.groups - -import android.content.Context -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.airbnb.epoxy.Carousel -import com.airbnb.epoxy.ModelView - -@ModelView(saveViewState = true, autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT) -class CarouselHorizontal(context: Context?) : Carousel(context) { - - override fun createLayoutManager(): LayoutManager = - LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) - - override fun getSnapHelperFactory(): Nothing? = null -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselModelGroup.kt b/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselModelGroup.kt deleted file mode 100644 index ced922487..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselModelGroup.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.groups - -import android.util.Log -import com.airbnb.epoxy.EpoxyModel -import com.airbnb.epoxy.EpoxyModelGroup -import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.store.R -import com.aurora.store.view.epoxy.controller.GenericCarouselController -import com.aurora.store.view.epoxy.views.HeaderViewModel_ -import com.aurora.store.view.epoxy.views.app.AppViewModel_ -import com.aurora.store.view.epoxy.views.shimmer.AppViewShimmerModel_ - -class CarouselModelGroup( - streamCluster: StreamCluster, - callbacks: GenericCarouselController.Callbacks -) : - EpoxyModelGroup( - R.layout.model_carousel_group, - buildModels( - streamCluster, - callbacks - ) - ) { - companion object { - private const val TAG = "CarouselModelGroup" - - private fun buildModels( - streamCluster: StreamCluster, - callbacks: GenericCarouselController.Callbacks - ): List> { - val models = ArrayList>() - val clusterViewModels = mutableListOf>() - - val idPrefix = streamCluster.clusterTitle.hashCode() - - models.add( - HeaderViewModel_() - .id("${idPrefix}_header") - .title(streamCluster.clusterTitle) - .browseUrl(streamCluster.clusterBrowseUrl) - .click { _ -> - callbacks.onHeaderClicked(streamCluster) - } - ) - - for (app in streamCluster.clusterAppList) { - clusterViewModels.add( - AppViewModel_() - .id(app.id) - .app(app) - .click { _ -> - callbacks.onAppClick(app) - } - .longClick { _ -> - callbacks.onAppLongClick(app) - false - } - .onBind { _, _, position -> - val itemCount = clusterViewModels.count() - if (itemCount >= 2) { - if (position == clusterViewModels.count() - 2) { - callbacks.onClusterScrolled(streamCluster) - Log.i(TAG, "Cluster ${streamCluster.clusterTitle} Scrolled") - } - } - } - ) - } - - if (streamCluster.hasNext()) { - clusterViewModels.add( - AppViewShimmerModel_() - .id("${idPrefix}_progress") - ) - } - - models.add( - CarouselHorizontalModel_() - .id("${idPrefix}_cluster") - .models(clusterViewModels) - ) - - return models - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselShimmerGroup.kt b/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselShimmerGroup.kt deleted file mode 100644 index 8429a64f1..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/groups/CarouselShimmerGroup.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.groups - -import com.airbnb.epoxy.EpoxyModel -import com.airbnb.epoxy.EpoxyModelGroup -import com.aurora.store.R -import com.aurora.store.view.epoxy.views.shimmer.AppViewShimmerModel_ -import com.aurora.store.view.epoxy.views.shimmer.HeaderViewShimmerModel_ -import java.util.UUID - -class CarouselShimmerGroup : - EpoxyModelGroup( - R.layout.model_carousel_group, - buildModels() - ) { - companion object { - private fun buildModels(): List> { - val models = ArrayList>() - val clusterViewModels = mutableListOf>() - val idPrefix = UUID.randomUUID() - - for (i in 1..8) { - clusterViewModels.add( - AppViewShimmerModel_() - .id(i) - ) - } - - models.add( - HeaderViewShimmerModel_() - .id("shimmer_header") - ) - - models.add( - CarouselHorizontalModel_() - .id("cluster_$idPrefix") - .models(clusterViewModels) - ) - return models - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/AppProgressView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/AppProgressView.kt deleted file mode 100644 index 765baa871..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/AppProgressView.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views - -import android.content.Context -import android.util.AttributeSet -import com.airbnb.epoxy.ModelView -import com.aurora.store.databinding.ViewAppProgressBinding - -@ModelView(autoLayout = ModelView.Size.WRAP_WIDTH_WRAP_HEIGHT) -class AppProgressView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/BaseModel.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/BaseModel.kt deleted file mode 100644 index 919aedd03..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/BaseModel.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views - -import android.view.View -import android.view.animation.AnimationUtils -import com.airbnb.epoxy.EpoxyModel -import com.aurora.store.view.epoxy.views.app.AppListView - -abstract class BaseModel : EpoxyModel() { - - override fun bind(view: T) { - super.bind(view) - when (view) { - is AppListView -> { - view.startAnimation( - AnimationUtils.loadAnimation( - view.context, - android.R.anim.fade_in - ) - ) - } - } - } - - override fun unbind(view: T) { - when (view) { - is AppListView -> { - view.clearAnimation() - } - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/BaseView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/BaseView.kt deleted file mode 100644 index 8f6b86fd4..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/BaseView.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.aurora.store.view.epoxy.views - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.RelativeLayout -import androidx.viewbinding.ViewBinding -import java.lang.reflect.ParameterizedType - -abstract class BaseView : RelativeLayout { - - private lateinit var _binding: ViewBindingType - protected val binding get() = _binding - - constructor(context: Context?) : super(context) { - init(context) - } - - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) { - init(context) - } - - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr - ) { - init(context) - } - - private fun init(context: Context?) { - if (context != null) { - _binding = inflateViewBinding(LayoutInflater.from(context)) - addView(_binding.root) - } - } - - @Suppress("UNCHECKED_CAST") - private fun inflateViewBinding(inflater: LayoutInflater): ViewBindingType { - val type = (javaClass.genericSuperclass as ParameterizedType) - .actualTypeArguments[0] as Class - val method = type.getMethod( - "inflate", - LayoutInflater::class.java, - ViewGroup::class.java, - Boolean::class.java - ) - return method.invoke(null, inflater, this, false) as ViewBindingType - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/CategoryView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/CategoryView.kt deleted file mode 100644 index 7f340c8fe..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/CategoryView.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views - -import android.content.Context -import android.util.AttributeSet -import coil3.load -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.aurora.gplayapi.data.models.Category -import com.aurora.store.databinding.ViewCategoryBinding - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class CategoryView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - - @ModelProp - fun category(category: Category) { - binding.txtName.text = category.title - binding.imgBackground.load(category.imageUrl) - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.root.setOnClickListener(onClickListener) - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/HeaderView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/HeaderView.kt deleted file mode 100644 index 1d8b097d1..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/HeaderView.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views - -import android.content.Context -import android.util.AttributeSet -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.airbnb.epoxy.OnViewRecycled -import com.aurora.store.databinding.ViewHeaderBinding - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class HeaderView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - - @ModelProp - fun title(title: String) { - binding.txtTitle.text = title - } - - @JvmOverloads - @ModelProp - fun browseUrl(browseUrl: String? = String()) { - if (browseUrl.isNullOrEmpty()) { - binding.imgAction.visibility = INVISIBLE - } - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.root.setOnClickListener(onClickListener) - } - - @OnViewRecycled - fun clear() { - binding.imgAction.visibility = VISIBLE - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/UpdateHeaderView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/UpdateHeaderView.kt deleted file mode 100644 index 2f9607652..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/UpdateHeaderView.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views - -import android.content.Context -import android.util.AttributeSet -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.airbnb.epoxy.OnViewRecycled -import com.aurora.store.databinding.ViewHeaderUpdateBinding - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class UpdateHeaderView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - - @ModelProp - fun title(title: String) { - binding.txtTitle.text = title - } - - @ModelProp - fun action(action: String) { - binding.btnAction.text = action - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.btnAction.setOnClickListener(onClickListener) - } - - @OnViewRecycled - fun clear() { - binding.btnAction.isEnabled = true - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppListView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppListView.kt deleted file mode 100644 index 105026465..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppListView.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.app - -import android.content.Context -import android.util.AttributeSet -import androidx.core.content.ContextCompat -import coil3.load -import coil3.request.placeholder -import coil3.request.transformations -import coil3.transform.RoundedCornersTransformation -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.aurora.gplayapi.data.models.App -import com.aurora.store.R -import com.aurora.store.databinding.ViewAppListBinding -import com.aurora.store.util.CommonUtil -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class AppListView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - - @ModelProp - fun app(app: App) { - binding.imgIcon.load(app.iconArtwork.url) { - placeholder(R.drawable.bg_placeholder) - transformations(RoundedCornersTransformation(25F)) - } - - binding.txtLine1.text = app.displayName - binding.txtLine2.text = app.developerName - - val extras: MutableList = mutableListOf() - extras.add(if (app.size > 0) CommonUtil.addSiPrefix(app.size) else app.downloadString) - extras.add("${app.labeledRating}★") - extras.add( - if (app.isFree) { - ContextCompat.getString(context, R.string.details_free) - } else { - ContextCompat.getString(context, R.string.details_paid) - } - ) - - if (app.containsAds) { - extras.add(ContextCompat.getString(context, R.string.details_contains_ads)) - } - - if (app.dependencies.dependentPackages.isNotEmpty()) { - extras.add(ContextCompat.getString(context, R.string.details_gsf_dependent)) - } - - binding.txtLine3.text = extras.joinToString(separator = " • ") - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.root.setOnClickListener(onClickListener) - } - - @CallbackProp - fun longClick(onClickListener: OnLongClickListener?) { - binding.root.setOnLongClickListener(onClickListener) - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppUpdateView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppUpdateView.kt deleted file mode 100644 index 693f67ca4..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppUpdateView.kt +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.app - -import android.animation.ObjectAnimator -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.View -import android.view.animation.AccelerateDecelerateInterpolator -import androidx.core.content.ContextCompat -import androidx.core.text.HtmlCompat -import androidx.core.view.isVisible -import coil3.asDrawable -import coil3.load -import coil3.request.placeholder -import coil3.request.transformations -import coil3.transform.CircleCropTransformation -import coil3.transform.RoundedCornersTransformation -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.airbnb.epoxy.OnViewRecycled -import com.aurora.extensions.invisible -import com.aurora.extensions.px -import com.aurora.store.R -import com.aurora.store.data.model.DownloadStatus -import com.aurora.store.data.room.download.Download -import com.aurora.store.data.room.update.Update -import com.aurora.store.databinding.ViewAppUpdateBinding -import com.aurora.store.util.CommonUtil -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class AppUpdateView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - private var iconDrawable: Drawable? = null - private val cornersTransformation = RoundedCornersTransformation(8.px.toFloat()) - - @ModelProp - fun update(update: Update) { - /*Inflate App details*/ - with(update) { - binding.txtLine1.text = displayName - binding.imgIcon.load(iconURL) { - placeholder(R.drawable.bg_placeholder) - transformations(cornersTransformation) - listener { _, result -> - result.image.asDrawable(resources).let { iconDrawable = it } - } - } - - binding.txtLine2.text = developerName - binding.txtLine3.text = ("${CommonUtil.addSiPrefix(size)} • $updatedOn") - binding.txtLine4.text = ("$versionName ($versionCode)") - binding.txtChangelog.text = if (changelog.isNotEmpty()) { - HtmlCompat.fromHtml( - changelog, - HtmlCompat.FROM_HTML_OPTION_USE_CSS_COLORS - ) - } else { - context.getString(R.string.details_changelog_unavailable) - } - - binding.headerIndicator.setOnClickListener { - if (binding.cardChangelog.isVisible) { - binding.headerIndicator.icon = - ContextCompat.getDrawable(context, R.drawable.ic_arrow_down) - binding.cardChangelog.visibility = View.GONE - } else { - binding.headerIndicator.icon = - ContextCompat.getDrawable(context, R.drawable.ic_arrow_up) - binding.cardChangelog.visibility = View.VISIBLE - } - } - } - } - - @ModelProp - fun download(download: Download?) { - if (download != null) { - binding.btnAction.updateState(download.status) - when (download.status) { - DownloadStatus.VERIFYING, - DownloadStatus.QUEUED -> { - binding.progressDownload.isIndeterminate = true - animateImageView(scaleFactor = 0.75f) - } - - DownloadStatus.DOWNLOADING -> { - binding.progressDownload.isIndeterminate = false - binding.progressDownload.progress = download.progress - animateImageView(scaleFactor = 0.75f) - } - - else -> { - binding.progressDownload.isIndeterminate = true - animateImageView(scaleFactor = 1f) - } - } - } - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.layoutContent.setOnClickListener(onClickListener) - } - - @CallbackProp - fun positiveAction(onClickListener: OnClickListener?) { - binding.btnAction.addPositiveOnClickListener(onClickListener) - } - - @CallbackProp - fun negativeAction(onClickListener: OnClickListener?) { - binding.btnAction.addNegativeOnClickListener(onClickListener) - } - - @CallbackProp - fun longClick(onClickListener: OnLongClickListener?) { - binding.layoutContent.setOnLongClickListener(onClickListener) - } - - @OnViewRecycled - fun clear() { - iconDrawable = null - - binding.apply { - headerIndicator.removeCallbacks {} - progressDownload.invisible() - btnAction.apply { - removeCallbacks { } - updateState(DownloadStatus.UNAVAILABLE) - } - } - } - - private fun animateImageView(scaleFactor: Float = 1f) { - val isDownloadVisible = binding.progressDownload.isShown - - // Avoids flickering when the download is in progress - if (isDownloadVisible && scaleFactor != 1f) { - return - } - - if (!isDownloadVisible && scaleFactor == 1f) { - return - } - - if (scaleFactor == 1f) { - binding.progressDownload.invisible() - } else { - binding.progressDownload.show() - } - - val scale = listOf( - ObjectAnimator.ofFloat(binding.imgIcon, "scaleX", scaleFactor), - ObjectAnimator.ofFloat(binding.imgIcon, "scaleY", scaleFactor) - ) - - scale.forEach { animation -> - animation.apply { - interpolator = AccelerateDecelerateInterpolator() - duration = 250 - start() - } - } - - iconDrawable?.let { - binding.imgIcon.load(it) { - transformations( - if (scaleFactor == 1f) { - cornersTransformation - } else { - CircleCropTransformation() - } - ) - } - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppView.kt deleted file mode 100644 index 5ea989447..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppView.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.app - -import android.content.Context -import android.util.AttributeSet -import coil3.load -import coil3.request.placeholder -import coil3.request.transformations -import coil3.transform.RoundedCornersTransformation -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.aurora.gplayapi.data.models.App -import com.aurora.store.R -import com.aurora.store.databinding.ViewAppBinding -import com.aurora.store.util.CommonUtil -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.WRAP_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class AppView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - - @ModelProp - fun app(app: App) { - binding.txtName.text = app.displayName - binding.imgIcon.load(app.iconArtwork.url) { - placeholder(R.drawable.bg_placeholder) - transformations(RoundedCornersTransformation(32F)) - } - - if (app.size > 0) { - binding.txtSize.text = CommonUtil.addSiPrefix(app.size) - } else { - binding.txtSize.text = app.downloadString - } - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.root.setOnClickListener(onClickListener) - } - - @CallbackProp - fun longClick(onClickListener: OnLongClickListener?) { - binding.root.setOnLongClickListener(onClickListener) - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/app/NoAppView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/app/NoAppView.kt deleted file mode 100644 index dbb9efa88..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/app/NoAppView.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.app - -import android.content.Context -import android.util.AttributeSet -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import coil3.load -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.aurora.store.databinding.ViewNoAppBinding -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_MATCH_HEIGHT, - baseModelClass = BaseModel::class -) -class NoAppView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - - @ModelProp - fun message(@StringRes message: Int) { - binding.txt.text = context.getString(message) - } - - @ModelProp - fun icon(@DrawableRes icon: Int) { - binding.img.load(icon) - } - - @JvmOverloads - @ModelProp - fun showAction(visibility: Boolean = false) { - binding.button.isVisible = visibility - } - - @JvmOverloads - @ModelProp - fun actionMessage(@StringRes message: Int? = null) { - message?.let { binding.button.text = context.getString(message) } - } - - @JvmOverloads - @CallbackProp - fun actionCallback(viewOnClickListener: OnClickListener? = null) { - binding.button.setOnClickListener(viewOnClickListener) - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/AppListViewShimmer.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/AppListViewShimmer.kt deleted file mode 100644 index 58baf31cf..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/AppListViewShimmer.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.shimmer - -import android.content.Context -import android.util.AttributeSet -import com.airbnb.epoxy.ModelView -import com.aurora.store.databinding.ViewAppListShimmerBinding -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.WRAP_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class AppListViewShimmer @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/AppViewShimmer.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/AppViewShimmer.kt deleted file mode 100644 index 79ad12368..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/AppViewShimmer.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.shimmer - -import android.content.Context -import android.util.AttributeSet -import com.airbnb.epoxy.ModelView -import com.aurora.store.databinding.ViewAppShimmerBinding -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.WRAP_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class AppViewShimmer @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/HeaderViewShimmer.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/HeaderViewShimmer.kt deleted file mode 100644 index ed8479d78..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/shimmer/HeaderViewShimmer.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.shimmer - -import android.content.Context -import android.util.AttributeSet -import com.airbnb.epoxy.ModelView -import com.aurora.store.databinding.ViewHeaderShimmerBinding -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.WRAP_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class HeaderViewShimmer @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) diff --git a/app/src/main/java/com/aurora/store/view/ui/account/GoogleFragment.kt b/app/src/main/java/com/aurora/store/view/ui/account/GoogleFragment.kt deleted file mode 100644 index 3ba18ccdb..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/account/GoogleFragment.kt +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.account - -import android.os.Build -import android.os.Bundle -import android.view.View -import android.webkit.CookieManager -import android.webkit.WebChromeClient -import android.webkit.WebSettings -import android.webkit.WebView -import android.webkit.WebViewClient -import android.widget.Toast -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import com.aurora.gplayapi.helpers.AuthHelper -import com.aurora.store.AuroraApp -import com.aurora.store.R -import com.aurora.store.data.event.AuthEvent -import com.aurora.store.databinding.FragmentGoogleBinding -import com.aurora.store.util.AC2DMUtil -import com.aurora.store.view.ui.commons.BaseFragment -import com.aurora.store.viewmodel.auth.AuthViewModel -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class GoogleFragment : BaseFragment() { - - private val viewModel: AuthViewModel by activityViewModels() - - companion object { - const val EMBEDDED_SETUP_URL = "https://accounts.google.com/EmbeddedSetup" - const val AUTH_TOKEN = "oauth_token" - private const val JS_SCRIPT = - "(function() { return document.getElementById('profileIdentifier').innerHTML; })();" - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val cookieManager = CookieManager.getInstance() - - binding.webview.apply { - cookieManager.removeAllCookies(null) - cookieManager.acceptThirdPartyCookies(this) - cookieManager.setAcceptThirdPartyCookies(this, true) - - webChromeClient = object : WebChromeClient() { - override fun onProgressChanged(view: WebView?, newProgress: Int) { - super.onProgressChanged(view, newProgress) - - if (newProgress != 0) { - binding.progressBar.also { - it.isVisible = newProgress < 100 - it.isIndeterminate = false - it.max = 100 - it.progress = newProgress - } - } else { - binding.progressBar.isIndeterminate = true - } - } - } - - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView, url: String) { - val cookies = CookieManager.getInstance().getCookie(url) - // cookies can be null if there is an error - if (cookies != null) { - val cookieMap = AC2DMUtil.parseCookieString(cookies) - if (cookieMap.isNotEmpty() && cookieMap[AUTH_TOKEN] != null) { - val oauthToken = cookieMap[AUTH_TOKEN] - evaluateJavascript(JS_SCRIPT) { - val email = it.replace("\"".toRegex(), "") - viewModel.buildAuthData(view.context, email, oauthToken) - } - } - } - } - } - - settings.apply { - allowContentAccess = true - domStorageEnabled = true - javaScriptEnabled = true - cacheMode = WebSettings.LOAD_DEFAULT - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) safeBrowsingEnabled = false - } - loadUrl(EMBEDDED_SETUP_URL) - } - - viewLifecycleOwner.lifecycleScope.launch { - AuroraApp.events.authEvent.collect { event -> - if (event is AuthEvent.GoogleLogin) onEventReceived(event) - } - } - } - - private fun onEventReceived(event: AuthEvent.GoogleLogin) { - if (event.success) { - viewModel.buildGoogleAuthData(event.email, event.token, AuthHelper.Token.AAS) - } else { - Toast.makeText( - requireContext(), - getString(R.string.toast_aas_token_failed), - Toast.LENGTH_LONG - ).show() - } - - findNavController().navigate( - GoogleFragmentDirections.actionGoogleFragmentToSplashFragment() - ) - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/apps/AppsContainerFragment.kt b/app/src/main/java/com/aurora/store/view/ui/apps/AppsContainerFragment.kt deleted file mode 100644 index d88f07fca..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/apps/AppsContainerFragment.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.apps - -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.navigation.fragment.findNavController -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.aurora.extensions.navigate -import com.aurora.store.MobileNavigationDirections -import com.aurora.store.R -import com.aurora.store.compose.navigation.Screen -import com.aurora.store.databinding.FragmentAppsGamesBinding -import com.aurora.store.util.Preferences -import com.aurora.store.view.ui.commons.BaseFragment -import com.aurora.store.view.ui.commons.CategoryFragment -import com.aurora.store.view.ui.commons.ForYouFragment -import com.aurora.store.view.ui.commons.TopChartContainerFragment -import com.aurora.store.viewmodel.apps.AppsContainerViewModel -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class AppsContainerFragment : BaseFragment() { - - private val viewModel: AppsContainerViewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Adjust FAB margins for edgeToEdge display - ViewCompat.setOnApplyWindowInsetsListener(binding.searchFab) { _, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) - binding.searchFab.updateLayoutParams { - bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.margin_large) - } - WindowInsetsCompat.CONSUMED - } - - // Toolbar - binding.toolbar.apply { - title = getString(R.string.title_apps) - setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_download_manager -> { - requireContext().navigate(Screen.Downloads) - } - - R.id.menu_more -> { - findNavController().navigate( - MobileNavigationDirections.actionGlobalMoreDialogFragment() - ) - } - } - true - } - } - - // ViewPager - val isForYouEnabled = Preferences.getBoolean( - requireContext(), - Preferences.PREFERENCE_FOR_YOU - ) - - binding.pager.adapter = ViewPagerAdapter( - childFragmentManager, - viewLifecycleOwner.lifecycle, - !viewModel.authProvider.isAnonymous, - isForYouEnabled - ) - - binding.pager.isUserInputEnabled = - false // Disable viewpager scroll to avoid scroll conflicts - - val tabTitles: MutableList = mutableListOf().apply { - if (isForYouEnabled) { - add(getString(R.string.tab_for_you)) - } - - add(getString(R.string.tab_top_charts)) - add(getString(R.string.tab_categories)) - } - - TabLayoutMediator( - binding.tabLayout, - binding.pager, - true - ) { tab: TabLayout.Tab, position: Int -> - tab.text = tabTitles[position] - }.attach() - - binding.searchFab.setOnClickListener { - requireContext().navigate(Screen.Search) - } - } - - override fun onDestroyView() { - binding.pager.adapter = null - super.onDestroyView() - } - - internal class ViewPagerAdapter( - fragment: FragmentManager, - lifecycle: Lifecycle, - private val isGoogleAccount: Boolean, - private val isForYouEnabled: Boolean - ) : - FragmentStateAdapter(fragment, lifecycle) { - - private val tabFragments: MutableList = mutableListOf().apply { - if (isForYouEnabled) { - add(ForYouFragment.newInstance(0)) - } - add(TopChartContainerFragment.newInstance(0)) - add(CategoryFragment.newInstance(0)) - } - - override fun createFragment(position: Int): Fragment = tabFragments[position] - - override fun getItemCount(): Int = tabFragments.size - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt deleted file mode 100644 index ccd05d12f..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.commons - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import androidx.viewbinding.ViewBinding -import com.airbnb.epoxy.EpoxyRecyclerView -import com.aurora.extensions.TAG -import com.aurora.extensions.navigate -import com.aurora.gplayapi.data.models.Category -import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.store.MobileNavigationDirections -import com.aurora.store.compose.navigation.Screen -import com.aurora.store.data.model.MinimalApp -import com.aurora.store.data.providers.PermissionProvider -import java.lang.reflect.ParameterizedType - -abstract class BaseFragment : Fragment() { - - lateinit var permissionProvider: PermissionProvider - - protected open var viewBindingType: ViewBindingType? = null - protected val binding get() = viewBindingType!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - permissionProvider = PermissionProvider(this) - } - - @Suppress("UNCHECKED_CAST") - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val type = (javaClass.genericSuperclass as ParameterizedType) - .actualTypeArguments[0] as Class - val method = type.getMethod( - "inflate", - LayoutInflater::class.java, - ViewGroup::class.java, - Boolean::class.java - ) - viewBindingType = method.invoke(null, inflater, container, false) as ViewBindingType - - return binding.root - } - - override fun onDestroy() { - permissionProvider.unregister() - super.onDestroy() - } - - override fun onDestroyView() { - cleanupRecyclerViews(findAllRecyclerViews(requireView())) - viewBindingType = null - super.onDestroyView() - } - - fun openDetailsFragment(packageName: String) { - requireContext().navigate( - Screen.AppDetails(packageName) - ) - } - - fun openCategoryBrowseFragment(category: Category) { - findNavController().navigate( - MobileNavigationDirections.actionGlobalCategoryBrowseFragment( - category.title, - category.browseUrl - ) - ) - } - - fun openStreamBrowseFragment(browseUrl: String, title: String = "") { - if (browseUrl.lowercase().contains("expanded")) { - findNavController().navigate( - MobileNavigationDirections.actionGlobalExpandedStreamBrowseFragment( - title, - browseUrl - ) - ) - } else if (browseUrl.lowercase().contains("developer")) { - findNavController().navigate( - MobileNavigationDirections.actionGlobalDevProfileFragment( - browseUrl.substringAfter("developer-"), - title - ) - ) - } - } - - fun openStreamBrowseFragment(streamCluster: StreamCluster) { - requireContext().navigate(Screen.StreamBrowse(streamCluster)) - } - - fun openAppMenuSheet(app: MinimalApp) { - findNavController().navigate(MobileNavigationDirections.actionGlobalAppMenuSheet(app)) - } - - fun openGMSWarningFragment() { - // TODO: FIX ME - } - - private fun cleanupRecyclerViews(recyclerViews: List) { - recyclerViews.forEach { recyclerView -> - runCatching { - recyclerView.adapter?.let { - recyclerView.swapAdapter(it, true) - } - }.onFailure { - Log.e(TAG, "Failed to cleanup RecyclerView", it) - } - } - } - - private fun findAllRecyclerViews(view: View): List { - val recyclerViews = mutableListOf() - - if (view is EpoxyRecyclerView) { - recyclerViews.add(view) - } else if (view is ViewGroup) { - for (i in 0 until view.childCount) { - recyclerViews.addAll(findAllRecyclerViews(view.getChildAt(i))) - } - } - - return recyclerViews - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/CategoryBrowseFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/CategoryBrowseFragment.kt deleted file mode 100644 index 319631470..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/CategoryBrowseFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.commons - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import com.aurora.gplayapi.data.models.App -import com.aurora.gplayapi.data.models.StreamBundle -import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.store.data.model.ViewState -import com.aurora.store.data.model.ViewState.Loading.getDataAs -import com.aurora.store.databinding.FragmentGenericWithToolbarBinding -import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener -import com.aurora.store.view.epoxy.controller.CategoryCarouselController -import com.aurora.store.view.epoxy.controller.GenericCarouselController -import com.aurora.store.viewmodel.subcategory.CategoryStreamViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class CategoryBrowseFragment : - BaseFragment(), - GenericCarouselController.Callbacks { - private val args: CategoryBrowseFragmentArgs by navArgs() - private val viewModel: CategoryStreamViewModel by activityViewModels() - - private var streamBundle: StreamBundle? = StreamBundle() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val genericCarouselController = CategoryCarouselController(this) - - // Toolbar - binding.toolbar.apply { - title = args.title - setNavigationOnClickListener { findNavController().navigateUp() } - } - - // RecyclerView - binding.recycler.setController(genericCarouselController) - binding.recycler.addOnScrollListener(object : EndlessRecyclerOnScrollListener() { - override fun onLoadMore(currentPage: Int) { - viewModel.observe(args.browseUrl) - } - }) - - viewModel.getStreamBundle(args.browseUrl) - viewModel.liveData.observe(viewLifecycleOwner) { - when (it) { - is ViewState.Loading -> { - genericCarouselController.setData(null) - } - - is ViewState.Success<*> -> { - val stash = it.getDataAs>() - streamBundle = stash[args.browseUrl] - - genericCarouselController.setData(streamBundle) - } - - else -> {} - } - } - } - - override fun onHeaderClicked(streamCluster: StreamCluster) { - openStreamBrowseFragment(streamCluster) - } - - override fun onClusterScrolled(streamCluster: StreamCluster) { - viewModel.observeCluster(args.browseUrl, streamCluster) - } - - override fun onAppClick(app: App) { - openDetailsFragment(app.packageName) - } - - override fun onAppLongClick(app: App) { - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/CategoryFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/CategoryFragment.kt deleted file mode 100644 index a81c6c344..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/CategoryFragment.kt +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.commons - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import com.aurora.Constants -import com.aurora.gplayapi.data.models.Category -import com.aurora.store.CategoryStash -import com.aurora.store.data.model.ViewState -import com.aurora.store.data.model.ViewState.Empty.getDataAs -import com.aurora.store.databinding.FragmentGenericRecyclerBinding -import com.aurora.store.view.epoxy.views.CategoryViewModel_ -import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_ -import com.aurora.store.viewmodel.category.CategoryViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class CategoryFragment : BaseFragment() { - private val viewModel: CategoryViewModel by viewModels() - - companion object { - @JvmStatic - fun newInstance(pageType: Int): CategoryFragment = CategoryFragment().apply { - arguments = Bundle().apply { - putInt(Constants.PAGE_TYPE, pageType) - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - var pageType = 0 - val bundle = arguments - if (bundle != null) { - pageType = bundle.getInt(Constants.PAGE_TYPE, 0) - } - - when (pageType) { - 0 -> viewModel.getCategoryList(Category.Type.APPLICATION) - 1 -> viewModel.getCategoryList(Category.Type.GAME) - } - - val categoryType = when (pageType) { - 1 -> Category.Type.GAME - else -> Category.Type.APPLICATION - } - - viewModel.liveData.observe(viewLifecycleOwner) { - when (it) { - is ViewState.Success<*> -> { - val stash = it.getDataAs() - updateController(stash[categoryType]) - } - - else -> { - updateController(emptyList()) - } - } - } - } - - fun updateController(categories: List?) { - binding.recycler.withModels { - if (categories == null) { - for (i in 1..10) { - add( - AppListViewShimmerModel_() - .id(i) - ) - } - } else { - setFilterDuplicates(true) - categories.forEach { - add( - CategoryViewModel_() - .id(it.title) - .category(it) - .click { _ -> openCategoryBrowseFragment(it) } - ) - } - } - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/ExpandedStreamBrowseFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/ExpandedStreamBrowseFragment.kt deleted file mode 100644 index 667cb6da0..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/ExpandedStreamBrowseFragment.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.commons - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.store.databinding.FragmentGenericWithToolbarBinding -import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener -import com.aurora.store.view.epoxy.views.AppProgressViewModel_ -import com.aurora.store.view.epoxy.views.app.AppListViewModel_ -import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_ -import com.aurora.store.viewmodel.browse.ExpandedStreamBrowseViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class ExpandedStreamBrowseFragment : BaseFragment() { - private val args: ExpandedStreamBrowseFragmentArgs by navArgs() - private val viewModel: ExpandedStreamBrowseViewModel by viewModels() - - private lateinit var endlessRecyclerOnScrollListener: EndlessRecyclerOnScrollListener - private lateinit var cluster: StreamCluster - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Toolbar - binding.toolbar.apply { - title = args.title - setNavigationOnClickListener { findNavController().navigateUp() } - } - - viewModel.liveData.observe(viewLifecycleOwner) { - if (!::cluster.isInitialized) attachRecycler() - cluster = it - - updateController(cluster) - updateTitle(cluster) - } - - viewModel.getInitialCluster(args.expandedStreamUrl) - updateController(null) - } - - private fun updateTitle(streamCluster: StreamCluster) { - if (streamCluster.clusterTitle.isNotEmpty()) { - binding.toolbar.title = streamCluster.clusterTitle - } - } - - private fun attachRecycler() { - endlessRecyclerOnScrollListener = object : EndlessRecyclerOnScrollListener() { - override fun onLoadMore(currentPage: Int) { - viewModel.next() - } - } - binding.recycler.addOnScrollListener(endlessRecyclerOnScrollListener) - } - - private fun updateController(streamCluster: StreamCluster?) { - binding.recycler.withModels { - setFilterDuplicates(true) - if (streamCluster == null) { - for (i in 1..6) { - add( - AppListViewShimmerModel_() - .id(i) - ) - } - } else { - streamCluster.clusterAppList.forEach { - add( - AppListViewModel_() - .id(it.packageName.hashCode()) - .app(it) - .click { _ -> openDetailsFragment(it.packageName) } - ) - } - - if (streamCluster.hasNext()) { - add( - AppProgressViewModel_() - .id("progress") - ) - } - } - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/ForYouFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/ForYouFragment.kt deleted file mode 100644 index e8171bb74..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/ForYouFragment.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.commons - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.activityViewModels -import com.aurora.Constants -import com.aurora.gplayapi.data.models.App -import com.aurora.gplayapi.data.models.StreamBundle -import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.gplayapi.helpers.contracts.StreamContract.Category -import com.aurora.gplayapi.helpers.contracts.StreamContract.Type -import com.aurora.store.HomeStash -import com.aurora.store.data.model.ViewState -import com.aurora.store.data.model.ViewState.Loading.getDataAs -import com.aurora.store.databinding.FragmentForYouBinding -import com.aurora.store.view.custom.recycler.EndlessRecyclerOnScrollListener -import com.aurora.store.view.epoxy.controller.GenericCarouselController -import com.aurora.store.viewmodel.homestream.StreamViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class ForYouFragment : - BaseFragment(), - GenericCarouselController.Callbacks { - private val viewModel: StreamViewModel by activityViewModels() - - private var category: Category = Category.APPLICATION - private var streamBundle: StreamBundle? = StreamBundle() - - companion object { - @JvmStatic - fun newInstance(pageType: Int): ForYouFragment = ForYouFragment().apply { - arguments = Bundle().apply { - putInt(Constants.PAGE_TYPE, pageType) - } - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val genericCarouselController = GenericCarouselController(this) - - var pageType = 0 - val bundle = arguments - if (bundle != null) { - pageType = bundle.getInt(Constants.PAGE_TYPE, 0) - } - - category = if (pageType == 0) Category.APPLICATION else Category.GAME - - binding.recycler.setController(genericCarouselController) - binding.recycler.addOnScrollListener( - object : EndlessRecyclerOnScrollListener(visibleThreshold = 4) { - override fun onLoadMore(currentPage: Int) { - viewModel.observe(category, Type.HOME) - } - } - ) - - viewModel.getStreamBundle(category, Type.HOME) - viewModel.liveData.observe(viewLifecycleOwner) { - when (it) { - is ViewState.Loading -> { - genericCarouselController.setData(null) - } - - is ViewState.Success<*> -> { - val stash = it.getDataAs() - streamBundle = stash[category] - - genericCarouselController.setData(streamBundle) - } - - else -> {} - } - } - } - - override fun onHeaderClicked(streamCluster: StreamCluster) { - openStreamBrowseFragment(streamCluster) - } - - override fun onClusterScrolled(streamCluster: StreamCluster) { - viewModel.observeCluster(category, streamCluster) - } - - override fun onAppClick(app: App) { - openDetailsFragment(app.packageName) - } - - override fun onAppLongClick(app: App) { - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/ForceRestartDialog.kt b/app/src/main/java/com/aurora/store/view/ui/commons/ForceRestartDialog.kt deleted file mode 100644 index 01aeed464..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/ForceRestartDialog.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.aurora.store.view.ui.commons - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.aurora.store.R -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.jakewharton.processphoenix.ProcessPhoenix -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class ForceRestartDialog : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.force_restart_title) - .setMessage(R.string.force_restart_summary) - .setPositiveButton(getString(R.string.action_restart)) { _, _ -> - ProcessPhoenix.triggerRebirth(requireContext()) - } - .create() - - override fun onResume() { - super.onResume() - dialog?.setCancelable(false) - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/MoreDialogFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/MoreDialogFragment.kt deleted file mode 100644 index c6809ffc3..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/commons/MoreDialogFragment.kt +++ /dev/null @@ -1,460 +0,0 @@ -package com.aurora.store.view.ui.commons - -import android.app.Dialog -import android.content.Context -import android.os.Bundle -import androidx.annotation.DrawableRes -import androidx.annotation.IdRes -import androidx.annotation.StringRes -import androidx.compose.foundation.BorderStroke -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.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -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.setValue -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.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.util.fastForEach -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import coil3.request.crossfade -import com.aurora.Constants -import com.aurora.Constants.URL_TOS -import com.aurora.extensions.browse -import com.aurora.extensions.getStyledAttributeColor -import com.aurora.extensions.navigate -import com.aurora.extensions.setAppTheme -import com.aurora.store.MR -import com.aurora.store.R -import com.aurora.store.compose.navigation.Screen -import com.aurora.store.compose.theme.AuroraTheme -import com.aurora.store.util.Preferences -import com.aurora.store.viewmodel.commons.MoreViewModel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MoreDialogFragment : DialogFragment() { - - private val viewModel: MoreViewModel by viewModels() - - private var primaryColor: Color = Color.White - private var onPrimaryColor: Color = Color.Black - private var secondaryColor: Color = Color.White - private var onSecondaryColor: Color = Color.Black - - private abstract class Option( - @StringRes open val title: Int, - @DrawableRes open val icon: Int - ) - - private data class ViewOption( - override val title: Int, - override val icon: Int, - @IdRes val destinationID: Int - ) : Option(title, icon) - - private data class ComposeOption( - override val title: Int, - override val icon: Int, - val screen: Screen - ) : Option(title, icon) - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = - MaterialAlertDialogBuilder(requireContext()) - .setView(customDialogView(requireContext())) - .create() - - private fun customDialogView(context: Context): ComposeView = ComposeView(context).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - AuroraTheme { - primaryColor = - Color(requireContext().getStyledAttributeColor(MR.colorSurface)) - onPrimaryColor = - Color(requireContext().getStyledAttributeColor(MR.colorOnSurface)) - secondaryColor = - Color(requireContext().getStyledAttributeColor(MR.colorSecondaryContainer)) - onSecondaryColor = - Color( - requireContext().getStyledAttributeColor(MR.colorOnSecondaryContainer) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = primaryColor) - .verticalScroll(rememberScrollState()) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy( - 4.dp, - Alignment.CenterVertically - ) - ) { - AppBar(onBackgroundColor = onPrimaryColor) - AccountHeader( - backgroundColor = secondaryColor, - onBackgroundColor = onSecondaryColor - ) - Column( - modifier = Modifier - .clip( - RoundedCornerShape( - topStart = 2.dp, - topEnd = 2.dp, - bottomStart = 25.dp, - bottomEnd = 25.dp - ) - ) - .background(color = secondaryColor) - ) { - getOptions().fastForEach { option -> - OptionItem( - option = option, - tintColor = onPrimaryColor, - textColor = onSecondaryColor, - onClick = { - when (option) { - is ViewOption -> { - findNavController().navigate(option.destinationID) - } - - is ComposeOption -> context.navigate(option.screen) - } - } - ) - } - } - getExtraOptions().fastForEach { option -> - OptionItem( - option = option, - tintColor = onPrimaryColor, - textColor = onPrimaryColor, - onClick = { - when (option) { - is ViewOption -> { - findNavController().navigate(option.destinationID) - } - - is ComposeOption -> context.navigate(option.screen) - } - } - ) - } - Footer(onPrimaryColor) - } - } - } - } - - @Composable - fun AppBar(onBackgroundColor: Color) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(4.dp, 10.dp, 4.dp, 10.dp) - ) { - ThreeStateIconButton(tint = onBackgroundColor) - Text( - modifier = Modifier.wrapContentWidth(), - text = stringResource(id = R.string.app_name), - style = MaterialTheme.typography.titleMedium, - color = onBackgroundColor, - textAlign = TextAlign.Center - ) - - Image( - painter = painterResource(id = R.drawable.ic_cancel), - contentDescription = stringResource(id = R.string.action_cancel), - modifier = Modifier.clickable { - findNavController().navigateUp() - }, - colorFilter = ColorFilter.tint(onBackgroundColor) - ) - } - } - - @Composable - fun Footer(tintColor: Color) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterHorizontally) - ) { - TextButton(onClick = { requireContext().browse(Constants.URL_POLICY) }) { - Text( - text = stringResource(id = R.string.privacy_policy_title), - fontWeight = FontWeight.Light, - color = tintColor, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Text(text = "•", color = tintColor) - TextButton(onClick = { requireContext().browse(URL_TOS) }) { - Text( - text = stringResource(id = R.string.menu_terms), - fontWeight = FontWeight.Light, - color = tintColor, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - @Composable - private fun AccountHeader(backgroundColor: Color, onBackgroundColor: Color = Color.White) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip( - RoundedCornerShape( - topStart = 25.dp, - topEnd = 25.dp, - bottomStart = 2.dp, - bottomEnd = 2.dp - ) - ) - .background(color = backgroundColor) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(15.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(15.dp, Alignment.Start) - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data( - if (viewModel.authProvider.isAnonymous) { - R.mipmap.ic_launcher - } else { - viewModel.authProvider.authData?.userProfile?.artwork?.url - } - ) - .crossfade(true) - .build(), - contentDescription = stringResource(id = R.string.title_account_manager), - placeholder = painterResource(R.drawable.ic_account), - contentScale = ContentScale.Crop, - modifier = Modifier - .requiredSize(36.dp) - .clip(CircleShape) - ) - Column( - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start - ) { - Text( - text = if (viewModel.authProvider.isAnonymous) { - stringResource(R.string.account_anonymous) - } else { - viewModel.authProvider.authData?.userProfile?.name - ?: stringResource(R.string.status_unavailable) - }, - fontWeight = FontWeight.Normal, - color = onBackgroundColor, - fontSize = 15.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = if (viewModel.authProvider.isAnonymous) { - stringResource(R.string.account_anonymous_email) - } else { - viewModel.authProvider.authData?.userProfile?.email - ?: stringResource(R.string.status_unavailable) - }, - fontWeight = FontWeight.Light, - color = onBackgroundColor, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - OutlinedButton( - onClick = { requireContext().navigate(Screen.Accounts) }, - shape = RoundedCornerShape(12.dp), - border = BorderStroke( - 1.dp, - Color( - requireContext().getStyledAttributeColor( - androidx.appcompat.R.attr.colorControlHighlight - ) - ) - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.manage_account), - color = onBackgroundColor, - fontWeight = FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - @Composable - private fun OptionItem( - option: Option, - tintColor: Color = Color.Blue, - textColor: Color = Color.Black, - onClick: () -> Unit - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start) - ) { - Image( - painter = painterResource(id = option.icon), - contentDescription = stringResource(id = option.title), - colorFilter = ColorFilter.tint(color = tintColor), - modifier = Modifier - .padding(horizontal = 10.dp) - .requiredSize(23.dp) - ) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = option.title), - color = textColor, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - @Composable - fun ThreeStateIconButton(tint: Color = Color.White) { - var currentState by remember { - mutableStateOf( - Preferences.getInteger( - requireContext(), - Preferences.PREFERENCE_THEME_STYLE - ).let { - State.entries.getOrNull(it) - } ?: State.Auto - ) - } - - val iconRes = when (currentState) { - State.Light -> R.drawable.ic_light - State.Dark -> R.drawable.ic_dark - else -> R.drawable.ic_auto - } - - Image( - painter = painterResource(id = iconRes), - contentDescription = null, - modifier = Modifier.clickable { - currentState = when (currentState) { - State.Light -> State.Dark - State.Dark -> State.Auto - State.Auto -> State.Light - } - - Preferences.putInteger( - requireContext(), - Preferences.PREFERENCE_THEME_STYLE, - currentState.value - ) - - setAppTheme(currentState.value) - findNavController().navigateUp() - }, - colorFilter = ColorFilter.tint(tint) - ) - } - - enum class State(val value: Int) { - Auto(0), - Light(1), - Dark(2) - } - - private fun getOptions(): List