Merge branch 'dev' into 'master'

dev

See merge request AuroraOSS/AuroraStore!568
This commit is contained in:
Rahul Patel
2026-05-20 12:29:46 +05:30
267 changed files with 8209 additions and 10564 deletions

View File

@@ -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<ApplicationExtension> {
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)

View File

@@ -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')"
]
}
}

View File

@@ -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')"
]
}
}

View File

@@ -1,38 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
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()
}
}

View File

@@ -82,9 +82,9 @@
tools:targetApi="tiramisu">
<activity
android:name=".MainActivity"
android:name=".ComposeActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan">
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -103,16 +103,28 @@
<intent-filter>
<action android:name="android.intent.action.SHOW_APP_INFO" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<nav-graph android:value="@navigation/mobile_navigation" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="market" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="market.android.com" />
<data android:host="play.google.com" />
<data android:host="play.google.com" />
</intent-filter>
</activity>
<!-- Activity to host composable screens -->
<activity
android:name=".ComposeActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<!-- Notification Action (Download Complete) -->
<activity
android:name=".data.activity.InstallActivity"

View File

@@ -54,6 +54,15 @@ val isVAndAbove: Boolean
val isMIUI: Boolean
get() = !getSystemProperty("ro.miui.ui.version.name").isNullOrBlank()
val isHyperOS: Boolean
get() = !getSystemProperty("ro.mi.os.version.name").isNullOrBlank() ||
!getSystemProperty("ro.mi.os.version.code").isNullOrBlank()
val isGrapheneOS: Boolean
get() = Build.FINGERPRINT.contains("GrapheneOS", ignoreCase = true) ||
Build.HOST.contains("grapheneos", ignoreCase = true) ||
getSystemProperty("ro.build.flavor")?.contains("grapheneos", ignoreCase = true) == true
val isHuawei: Boolean
get() = Build.MANUFACTURER.lowercase(Locale.getDefault()).contains("huawei") ||
Build.HARDWARE.lowercase(Locale.getDefault()).contains("kirin") ||

View File

@@ -6,43 +6,43 @@
package com.aurora.store
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.getPackageName
import com.aurora.store.compose.composition.LocalNetworkStatus
import com.aurora.store.compose.composition.LocalUI
import com.aurora.store.compose.composition.UI
import com.aurora.store.compose.navigation.NavDisplay
import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.theme.AuroraTheme
import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.data.providers.NetworkProvider
import com.aurora.store.data.receiver.MigrationReceiver
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.Preferences
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class ComposeActivity : ComponentActivity() {
@Inject lateinit var networkProvider: NetworkProvider
override fun onCreate(savedInstanceState: Bundle?) {
MigrationReceiver.runMigrationsIfRequired(this)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// Ensure the classloader is set for the Screen parcelable
intent.setExtrasClassLoader(Screen::class.java.classLoader)
var startDestination = IntentCompat.getParcelableExtra(
intent,
Screen.PARCEL_KEY,
Screen::class.java
) ?: Screen.Onboarding
// If the intent contains a package name for app details,
// Override the start destination to be the app details screen for that package.
val packageName = intent.getPackageName()
if (packageName != null) {
startDestination = Screen.AppDetails(packageName)
}
val startDestination = resolveStartDestination()
val localUI = when {
PackageUtil.isTv(this) -> 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
}
}

View File

@@ -1,157 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
* 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 <http://www.gnu.org/licenses/>.
*
*/
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)
}

View File

@@ -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()

View File

@@ -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)"
)
}

View File

@@ -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

View File

@@ -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 = ""
)
)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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)
)
}
}
)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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)
},

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 = {}
)
}

View File

@@ -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()
}
}
}
}

View File

@@ -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()
) {

View File

@@ -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)

View File

@@ -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") } }
)
}

View File

@@ -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<Float>) {
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()
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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)
)
}

View File

@@ -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 = "",

View File

@@ -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
)
}
}

View File

@@ -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<br>• 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)
}

View File

@@ -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"
)
}

View File

@@ -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<String> = mutableListOf<String>().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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 <T> Flow<T>.collectForced(initial: T): State<T> {
val lifecycleOwner = LocalLifecycleOwner.current
val state: MutableState<T> = 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 <T> LiveData<T>.observeForced(): State<T?> {
val lifecycleOwner = LocalLifecycleOwner.current
val state: MutableState<T?> = remember { mutableStateOf(value, neverEqualPolicy()) }
DisposableEffect(this, lifecycleOwner) {
val observer = Observer<T> { state.value = it }
observe(lifecycleOwner, observer)
onDispose { removeObserver(observer) }
}
return state
}

View File

@@ -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 }

View File

@@ -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<PermissionType>) : 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()
}

View File

@@ -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<Screen.Blacklist> {
BlacklistScreen(onNavigateUp = ::onNavigateUp)
}
entry<Screen.Search> {
SearchScreen(onNavigateUp = ::onNavigateUp)
entry<Screen.Main> { screen ->
MainScreen(
initialTab = screen.initialTab,
onNavigateTo = ::navigate
)
}
entry<Screen.AppDetails> { screen ->
AppDetailsScreen(
packageName = screen.packageName,
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
onNavigateTo = ::navigate
)
}
entry<Screen.DevProfile> { screen ->
DevProfileScreen(
developerId = screen.developerId,
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
onNavigateTo = ::navigate
)
}
entry<Screen.PermissionRationale> { screen ->
PermissionRationaleScreen(
onNavigateUp = ::onNavigateUp,
requiredPermissions = screen.requiredPermissions
)
}
entry<Screen.Downloads> {
DownloadsScreen(
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
)
}
entry<Screen.Accounts> {
AccountsScreen(
onNavigateUp = ::onNavigateUp,
onNavigateToSplash = { activity?.startActivity(splashIntent) }
)
}
entry<Screen.About> {
AboutScreen(onNavigateUp = ::onNavigateUp)
}
entry<Screen.Favourite> {
FavouriteScreen(
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
)
}
entry<Screen.Onboarding> {
OnboardingScreen()
}
entry<Screen.Spoof> {
SpoofScreen(
onNavigateUp = ::onNavigateUp,
onNavigateToSplash = { activity?.startActivity(splashIntent) }
)
}
entry<Screen.Dispenser> {
DispenserScreen(onNavigateUp = ::onNavigateUp)
}
entry<Screen.Installer> {
InstallerScreen(onNavigateUp = ::onNavigateUp)
}
entry<Screen.Installed> {
InstalledScreen(
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
)
}
entry<Screen.StreamBrowse> { screen ->
StreamBrowseScreen(
streamCluster = screen.streamCluster,
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
onNavigateTo = ::navigate
)
}
entry<Screen.ExpandedStreamBrowse> { screen ->
ExpandedStreamBrowseScreen(
browseUrl = screen.browseUrl,
defaultTitle = screen.title,
onNavigateTo = ::navigate
)
}
entry<Screen.CategoryBrowse> { screen ->
CategoryBrowseScreen(
title = screen.title,
browseUrl = screen.browseUrl,
onNavigateTo = ::navigate
)
}
entry<Screen.InstallationPreference> {
InstallationPreferenceScreen(onNavigateTo = ::navigate)
}
entry<Screen.Search>(
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<Screen.Splash> { SplashScreen(onNavigateTo = ::navigate) }
entry<Screen.Onboarding> { OnboardingScreen() }
entry<Screen.Blacklist> { BlacklistScreen() }
entry<Screen.Downloads> { DownloadsScreen(onNavigateTo = ::navigate) }
entry<Screen.Accounts> { AccountsScreen(onNavigateTo = ::navigate) }
entry<Screen.GoogleLogin> { GoogleLoginScreen(onNavigateTo = ::navigate) }
entry<Screen.About> { AboutScreen() }
entry<Screen.Favourite> { FavouriteScreen(onNavigateTo = ::navigate) }
entry<Screen.Spoof> { SpoofScreen(onNavigateTo = ::navigate) }
entry<Screen.Dispenser> { DispenserScreen() }
entry<Screen.Installer> { InstallerScreen() }
entry<Screen.Installed> { InstalledScreen(onNavigateTo = ::navigate) }
entry<Screen.Settings> { SettingsScreen(onNavigateTo = ::navigate) }
entry<Screen.NetworkPreference> { NetworkPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.UIPreference> { UIPreferenceScreen() }
entry<Screen.UpdatesPreference> { UpdatesPreferenceScreen(onNavigateTo = ::navigate) }
entry<Screen.SourceFilters> { SourceFiltersScreen() }
}
)
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)
}
}
)
}
}

View File

@@ -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)) }
)
}
}
}
}

View File

@@ -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<Category>?, 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)
}

View File

@@ -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
)
}

View File

@@ -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<StreamCluster?>()
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<Int>,
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
)
}

View File

@@ -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<BlacklistAppItem>? = null,
onNavigateUp: () -> Unit = {},
sortFilterState: SortFilterState = SortFilterState(),
installers: Map<String, String> = 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)

View File

@@ -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() }
)
}
}
}

View File

@@ -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))
}
)
}
}
}
}
}
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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()
}

View File

@@ -35,14 +35,12 @@ import kotlin.random.Random
@Composable
fun PermissionRationaleScreen(
requiredPermissions: Set<PermissionType> = 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<Permission> = 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 ->

View File

@@ -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<AppType> = 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 <reified T : Enum<T>> enumValueOrDefault(name: String, default: T): T =
runCatching { enumValueOf<T>(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 <T : InstalledAppMeta> List<T>.applyFilter(state: SortFilterState): List<T> {
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 <T : InstalledAppMeta> List<T>.applySort(state: SortFilterState): List<T> {
val comparator: Comparator<T> = 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()
)
}

View File

@@ -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<String, String>,
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<String, String>,
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 = {}
)
}

View File

@@ -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<App> = 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))
}
)
}
}

View File

@@ -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<Review> = emptyList(),
suggestions: List<App> = 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) }
)
)
)
)
}

View File

@@ -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<ExodusTracker> = 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
)
}

View File

@@ -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),

View File

@@ -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)
)
}

View File

@@ -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<App>? = 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<App>,
onNavigateToAppDetails: (packageName: String) -> Unit
) {
Header(title = stringResource(R.string.details_dependencies))
private fun AppDependencies(dependencies: List<App>, 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)

View File

@@ -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<String, PermissionInfo> = 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)
)
}

View File

@@ -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<Review> = 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

View File

@@ -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<Artwork> = 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(

View File

@@ -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),

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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 = {})
}

View File

@@ -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)
}

View File

@@ -37,8 +37,8 @@ import com.aurora.store.compose.preview.ThemePreviewProvider
@Composable
fun Screenshots(screenshots: List<Artwork>, 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<Artwork>, 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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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<App>,
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)) }
)
}
}

View File

@@ -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<String> = 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(

View File

@@ -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(

View File

@@ -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<Download> = 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<Download?>(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)
)
}

View File

@@ -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<Favourite> = 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)
)
}

View File

@@ -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<App> = emptyPagingItems(),
onNavigateToAppDetails: (packageName: String) -> Unit = {}
state: SortFilterState = SortFilterState(),
installers: Map<String, String> = emptyMap(),
metadata: Map<String, InstalledAppMeta> = 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)

View File

@@ -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<Update?>(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() }
)
}
}
}
}
}

View File

@@ -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)
)
}

View File

@@ -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
)
) {

View File

@@ -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),

View File

@@ -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),

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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<DevicePolicyManager>()
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()
}

View File

@@ -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<InstallerInfo> = 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(

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