diff --git a/.gitignore b/.gitignore index b291c05ea..3ce5df563 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ app/.cxx/ # Kotlin 2.0 .kotlin/sessions *.salive +kls_database.db # Signing Configs *.jks diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES new file mode 100644 index 000000000..46bc7ba44 --- /dev/null +++ b/THIRD_PARTY_NOTICES @@ -0,0 +1,34 @@ +# Third-Party Notices + +This file contains notices for third-party assets and libraries used in this project. + +--- + +## Kenney Input Prompts + +**Asset Pack:** Input Prompts +**Author:** Kenney (https://kenney.nl) +**Source:** https://kenney.nl/assets/input-prompts +**License:** CC0 1.0 Universal (Public Domain) + +The input prompt icons used for gamepad, keyboard, and mouse button indicators +are from Kenney's Input Prompts asset pack. These icons are located in: +- `app/src/main/res/drawable/ic_input_xbox_*.xml` (Xbox controller icons) +- `app/src/main/res/drawable/ic_input_kbd_*.xml` (Keyboard and mouse icons) + +While CC0 doesn't require attribution, we gratefully acknowledge Kenney's +excellent work in creating these assets. + +--- + +## License Text: CC0 1.0 Universal + +The person who associated a work with this deed has dedicated the work to the +public domain by waiving all of his or her rights to the work worldwide under +copyright law, including all related and neighboring rights, to the extent +allowed by law. + +You can copy, modify, distribute and perform the work, even for commercial +purposes, all without asking permission. + +Full license text: https://creativecommons.org/publicdomain/zero/1.0/ diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt index c3da8bac2..fb874be95 100644 --- a/app/src/main/java/app/gamenative/MainActivity.kt +++ b/app/src/main/java/app/gamenative/MainActivity.kt @@ -8,29 +8,23 @@ import android.content.res.Configuration import android.graphics.Color.TRANSPARENT import android.os.Build import android.os.Bundle -import android.util.Log import android.view.KeyEvent import android.view.MotionEvent import android.view.OrientationEventListener import androidx.activity.ComponentActivity -import androidx.activity.OnBackPressedCallback import androidx.activity.SystemBarStyle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.CompositionLocalProvider -import androidx.lifecycle.lifecycleScope import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import coil.ImageLoader -import coil.disk.DiskCache -import coil.memory.MemoryCache -import coil.request.CachePolicy +import androidx.lifecycle.lifecycleScope import app.gamenative.events.AndroidEvent import app.gamenative.service.SteamService import app.gamenative.ui.PluviaMain @@ -40,14 +34,18 @@ import app.gamenative.utils.ContainerUtils import app.gamenative.utils.IconDecoder import app.gamenative.utils.IntentLaunchManager import app.gamenative.utils.LocaleHelper +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.CachePolicy import com.posthog.PostHog import com.skydoves.landscapist.coil.LocalCoilImageLoader import com.winlator.core.AppUtils import com.winlator.inputcontrols.ControllerManager import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import java.util.EnumSet import kotlin.math.abs +import kotlinx.coroutines.launch import okio.Path.Companion.toOkioPath import timber.log.Timber @@ -123,14 +121,18 @@ class MainActivity : ComponentActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // Full immersive mode - transparent system bars for console-like experience enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(android.graphics.Color.rgb(30, 30, 30)), - navigationBarStyle = SystemBarStyle.light(TRANSPARENT, TRANSPARENT), + statusBarStyle = SystemBarStyle.dark(TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(TRANSPARENT), ) super.onCreate(savedInstanceState) + // Apply immersive mode based on user preference + applyImmersiveMode() + // Initialize the controller management system - ControllerManager.getInstance().init(getApplicationContext()); + ControllerManager.getInstance().init(getApplicationContext()) ContainerUtils.setContainerDefaults(applicationContext) @@ -254,6 +256,9 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + // Re-apply immersive mode to ensure fullscreen persists + applyImmersiveMode() + // disable auto-stop when returning to foreground SteamService.autoStopWhenIdle = false @@ -317,7 +322,7 @@ class MainActivity : ComponentActivity() { // Since LibraryScreen uses its own navigation system, this will need to be re-worked accordingly. if (!eventDispatched) { if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_DOWN) { - if (SteamService.isGameRunning){ + if (SteamService.isGameRunning) { PluviaApp.events.emit(AndroidEvent.BackPressed) eventDispatched = true } @@ -361,6 +366,45 @@ class MainActivity : ComponentActivity() { orientationSensorListener?.takeIf { it.canDetectOrientation() }?.enable() } + /** + * Apply immersive mode for a full-screen experience. + * Must be called in multiple lifecycle methods to ensure bars stay hidden. + */ + private fun applyImmersiveMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Use WindowInsetsController for Android 11+ + window.setDecorFitsSystemWindows(false) // TODO: look into the proper way of doing this + window.insetsController?.let { controller -> + controller.hide( + android.view.WindowInsets.Type.statusBars() or + android.view.WindowInsets.Type.navigationBars(), + ) + // Allow transient bars to appear on swipe from edge + controller.systemBarsBehavior = + android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + // Legacy approach for older Android versions + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or android.view.View.SYSTEM_UI_FLAG_FULLSCREEN + or android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // Re-apply immersive mode when window gains focus to ensure bars stay hidden + if (hasFocus) { + applyImmersiveMode() + } + } + private fun setOrientationTo(orientation: Int, conformTo: EnumSet) { // Log.d("MainActivity$index", "Setting orientation to conform") diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index afc59ad3a..834106ea0 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -1,38 +1,31 @@ package app.gamenative import android.os.StrictMode -import androidx.lifecycle.ProcessLifecycleOwner import androidx.navigation.NavController -import app.gamenative.events.AndroidEvent import app.gamenative.events.EventDispatcher import app.gamenative.service.DownloadService import app.gamenative.utils.ContainerMigrator import app.gamenative.utils.IntentLaunchManager import com.google.android.play.core.splitcompat.SplitCompatApplication import com.posthog.PersonProfiles +import com.posthog.android.PostHogAndroid +import com.posthog.android.PostHogAndroidConfig import com.winlator.inputcontrols.InputControlsManager import com.winlator.widget.InputControlsView import com.winlator.widget.TouchpadView import com.winlator.widget.XServerView import com.winlator.xenvironment.XEnvironment import dagger.hilt.android.HiltAndroidApp -import timber.log.Timber - -// Add PostHog imports -import com.posthog.android.PostHogAndroid -import com.posthog.android.PostHogAndroidConfig - -// Supabase imports import io.github.jan.supabase.SupabaseClient import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.createSupabaseClient import io.github.jan.supabase.postgrest.Postgrest -import io.github.jan.supabase.network.supabaseApi import io.ktor.client.plugins.HttpTimeout import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import timber.log.Timber typealias NavChangedListener = NavController.OnDestinationChangedListener @@ -70,7 +63,7 @@ class PluviaApp : SplitCompatApplication() { ContainerMigrator.migrateLegacyContainersIfNeeded( context = applicationContext, onProgressUpdate = null, - onComplete = null + onComplete = null, ) } @@ -118,6 +111,8 @@ class PluviaApp : SplitCompatApplication() { lateinit var supabase: SupabaseClient private set + fun isSupabaseInitialized(): Boolean = ::supabase.isInitialized + // Initialize Supabase client @OptIn(SupabaseInternal::class) fun initSupabase() { @@ -129,15 +124,15 @@ class PluviaApp : SplitCompatApplication() { supabase = createSupabaseClient( supabaseUrl = BuildConfig.SUPABASE_URL, - supabaseKey = BuildConfig.SUPABASE_KEY + supabaseKey = BuildConfig.SUPABASE_KEY, ) { Timber.d("Configuring Supabase client") httpConfig { Timber.d("Setting up HTTP timeouts") install(HttpTimeout) { - requestTimeoutMillis = 30_000 // overall call - connectTimeoutMillis = 15_000 // TCP handshake / TLS - socketTimeoutMillis = 30_000 // idle socket + requestTimeoutMillis = 30_000 // overall call + connectTimeoutMillis = 15_000 // TCP handshake / TLS + socketTimeoutMillis = 30_000 // idle socket } } install(Postgrest) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 99296aecc..120b7ba8f 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -17,7 +17,6 @@ import app.gamenative.enums.AppTheme import app.gamenative.ui.enums.AppFilter import app.gamenative.ui.enums.HomeDestination import app.gamenative.ui.enums.Orientation -import app.gamenative.Constants import app.gamenative.ui.enums.PaneType import com.materialkolor.PaletteStyle import com.winlator.box86_64.Box86_64Preset @@ -542,6 +541,16 @@ object PrefManager { setPref(LIBRARY_FILTER, AppFilter.toFlags(value)) } + private val LIBRARY_SORT = intPreferencesKey("library_sort") + var librarySortOption: app.gamenative.ui.enums.SortOption + get() { + val value = getPref(LIBRARY_SORT, app.gamenative.ui.enums.SortOption.INSTALLED_FIRST.ordinal) + return app.gamenative.ui.enums.SortOption.fromOrdinal(value) + } + set(value) { + setPref(LIBRARY_SORT, value.ordinal) + } + /** * Get or Set the last known Persona State. See [EPersonaState] */ @@ -733,17 +742,6 @@ object PrefManager { setPref(EXTERNAL_STORAGE_PATH, value) } - // Custom Games root (additional paths). Default path is provided by the app at runtime and isn't stored here. - private val CUSTOM_GAME_PATHS = stringPreferencesKey("custom_game_paths") - var customGamePaths: Set - get() { - val value = getPref(CUSTOM_GAME_PATHS, "[]") - return try { Json.decodeFromString>(value) } catch (e: Exception) { emptySet() } - } - set(value) { - setPref(CUSTOM_GAME_PATHS, Json.encodeToString(value)) - } - private val CUSTOM_GAME_MANUAL_FOLDERS = stringPreferencesKey("custom_game_manual_folders") var customGameManualFolders: Set get() { diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index ca55fde9d..1ee839830 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -27,6 +27,8 @@ data class LibraryItem( val isShared: Boolean = false, val gameSource: GameSource = GameSource.STEAM, val compatibilityStatus: GameCompatibilityStatus? = null, + val sizeBytes: Long = 0L, + val isInstalled: Boolean = false, ) { val clientIconUrl: String get() = when (gameSource) { diff --git a/app/src/main/java/app/gamenative/data/SteamFriend.kt b/app/src/main/java/app/gamenative/data/SteamFriend.kt index 2105ce8ff..efc6cfc83 100644 --- a/app/src/main/java/app/gamenative/data/SteamFriend.kt +++ b/app/src/main/java/app/gamenative/data/SteamFriend.kt @@ -12,12 +12,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import app.gamenative.ui.component.icons.VR -import app.gamenative.ui.theme.friendAwayOrSnooze -import app.gamenative.ui.theme.friendBlocked -import app.gamenative.ui.theme.friendInGame -import app.gamenative.ui.theme.friendInGameAwayOrSnooze -import app.gamenative.ui.theme.friendOffline -import app.gamenative.ui.theme.friendOnline +import app.gamenative.ui.theme.DarkColors import `in`.dragonbra.javasteam.enums.EClientPersonaStateFlag import `in`.dragonbra.javasteam.enums.EFriendRelationship import `in`.dragonbra.javasteam.enums.EPersonaState @@ -121,13 +116,13 @@ data class SteamFriend( val statusColor: Color get() = when { - isBlocked -> friendBlocked - isOffline -> friendOffline - isInGameAwayOrSnooze -> friendInGameAwayOrSnooze - isAwayOrSnooze -> friendAwayOrSnooze - isPlayingGame -> friendInGame - isOnline -> friendOnline - else -> friendOffline + isBlocked -> DarkColors.friendBlocked + isOffline -> DarkColors.friendOffline + isInGameAwayOrSnooze -> DarkColors.friendInGameAwayOrSnooze + isAwayOrSnooze -> DarkColors.friendAwayOrSnooze + isPlayingGame -> DarkColors.friendInGame + isOnline -> DarkColors.friendOnline + else -> DarkColors.friendOffline } val statusIcon: ImageVector? diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 51d38c4e5..b776b7f13 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -8,15 +8,19 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.IBinder +import android.util.Base64 import android.widget.Toast import androidx.room.withTransaction import app.gamenative.BuildConfig -import app.gamenative.R import app.gamenative.PluviaApp import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.data.AppInfo +import app.gamenative.data.CachedLicense import app.gamenative.data.DepotInfo import app.gamenative.data.DownloadInfo import app.gamenative.data.Emoticon +import app.gamenative.data.EncryptedAppTicket import app.gamenative.data.GameProcessInfo import app.gamenative.data.LaunchInfo import app.gamenative.data.OwnedGames @@ -25,17 +29,19 @@ import app.gamenative.data.SteamApp import app.gamenative.data.SteamFriend import app.gamenative.data.SteamLicense import app.gamenative.data.UserFileInfo -import app.gamenative.data.EncryptedAppTicket import app.gamenative.db.PluviaDatabase +import app.gamenative.db.dao.AppInfoDao +import app.gamenative.db.dao.CachedLicenseDao import app.gamenative.db.dao.ChangeNumbersDao import app.gamenative.db.dao.EmoticonDao +import app.gamenative.db.dao.EncryptedAppTicketDao import app.gamenative.db.dao.FileChangeListsDao import app.gamenative.db.dao.FriendMessagesDao import app.gamenative.db.dao.SteamAppDao import app.gamenative.db.dao.SteamFriendDao import app.gamenative.db.dao.SteamLicenseDao -import app.gamenative.db.dao.CachedLicenseDao import app.gamenative.enums.LoginResult +import app.gamenative.enums.Marker import app.gamenative.enums.OS import app.gamenative.enums.OSArch import app.gamenative.enums.SaveLocation @@ -44,18 +50,17 @@ import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.callback.EmoticonListCallback import app.gamenative.service.handler.PluviaHandler +import app.gamenative.utils.LicenseSerializer +import app.gamenative.utils.MarkerUtils import app.gamenative.utils.SteamUtils import app.gamenative.utils.generateSteamApp -import com.google.android.play.core.ktx.bytesDownloaded -import com.google.android.play.core.ktx.requestCancelInstall -import com.google.android.play.core.ktx.requestInstall -import com.google.android.play.core.ktx.requestSessionState -import com.google.android.play.core.ktx.status -import com.google.android.play.core.ktx.totalBytesToDownload -import com.google.android.play.core.splitinstall.SplitInstallManagerFactory -import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus +import com.winlator.container.Container import com.winlator.xenvironment.ImageFs import dagger.hilt.android.AndroidEntryPoint +import `in`.dragonbra.javasteam.depotdownloader.DepotDownloader +import `in`.dragonbra.javasteam.depotdownloader.IDownloadListener +import `in`.dragonbra.javasteam.depotdownloader.data.AppItem +import `in`.dragonbra.javasteam.depotdownloader.data.DownloadItem import `in`.dragonbra.javasteam.enums.EDepotFileFlag import `in`.dragonbra.javasteam.enums.EFriendRelationship import `in`.dragonbra.javasteam.enums.ELicenseFlags @@ -72,11 +77,10 @@ import `in`.dragonbra.javasteam.steam.authentication.AuthenticationException import `in`.dragonbra.javasteam.steam.authentication.IAuthenticator import `in`.dragonbra.javasteam.steam.authentication.IChallengeUrlChanged import `in`.dragonbra.javasteam.steam.authentication.QrAuthSession -import `in`.dragonbra.javasteam.depotdownloader.DepotDownloader -import `in`.dragonbra.javasteam.depotdownloader.IDownloadListener import `in`.dragonbra.javasteam.steam.discovery.FileServerListProvider import `in`.dragonbra.javasteam.steam.discovery.ServerQuality import `in`.dragonbra.javasteam.steam.handlers.steamapps.GamePlayedInfo +import `in`.dragonbra.javasteam.steam.handlers.steamapps.License import `in`.dragonbra.javasteam.steam.handlers.steamapps.PICSRequest import `in`.dragonbra.javasteam.steam.handlers.steamapps.SteamApps import `in`.dragonbra.javasteam.steam.handlers.steamapps.callback.LicenseListCallback @@ -96,13 +100,16 @@ import `in`.dragonbra.javasteam.steam.handlers.steamuser.LogOnDetails import `in`.dragonbra.javasteam.steam.handlers.steamuser.SteamUser import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOffCallback import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.LoggedOnCallback +import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.PlayingSessionStateCallback import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.SteamUserStats import `in`.dragonbra.javasteam.steam.handlers.steamworkshop.SteamWorkshop +import `in`.dragonbra.javasteam.steam.steamclient.AsyncJobFailedException import `in`.dragonbra.javasteam.steam.steamclient.SteamClient import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackManager import `in`.dragonbra.javasteam.steam.steamclient.callbacks.ConnectedCallback import `in`.dragonbra.javasteam.steam.steamclient.callbacks.DisconnectedCallback import `in`.dragonbra.javasteam.steam.steamclient.configuration.SteamConfiguration +import `in`.dragonbra.javasteam.types.DepotManifest import `in`.dragonbra.javasteam.types.FileData import `in`.dragonbra.javasteam.types.SteamID import `in`.dragonbra.javasteam.util.NetHelpers @@ -110,12 +117,16 @@ import `in`.dragonbra.javasteam.util.log.LogListener import `in`.dragonbra.javasteam.util.log.LogManager import java.io.Closeable import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.lang.NullPointerException import java.nio.file.Files import java.nio.file.Paths import java.util.Collections import java.util.EnumSet import java.util.concurrent.CancellationException import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit import javax.inject.Inject import kotlin.io.path.pathString import kotlin.time.Duration.Companion.seconds @@ -124,14 +135,15 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.receiveAsFlow @@ -141,34 +153,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import timber.log.Timber -import java.lang.NullPointerException -import app.gamenative.data.AppInfo -import app.gamenative.db.dao.AppInfoDao -import kotlinx.coroutines.ensureActive -import app.gamenative.enums.Marker -import app.gamenative.utils.FileUtils -import app.gamenative.utils.MarkerUtils -import app.gamenative.utils.LicenseSerializer -import app.gamenative.data.CachedLicense -import com.winlator.container.Container -import `in`.dragonbra.javasteam.depotdownloader.data.AppItem -import `in`.dragonbra.javasteam.depotdownloader.data.DownloadItem -import `in`.dragonbra.javasteam.steam.handlers.steamapps.License -import `in`.dragonbra.javasteam.steam.handlers.steamuser.callback.PlayingSessionStateCallback -import `in`.dragonbra.javasteam.steam.steamclient.AsyncJobFailedException -import `in`.dragonbra.javasteam.types.DepotManifest -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import okhttp3.OkHttpClient import okhttp3.Request -import android.util.Base64 -import app.gamenative.db.dao.EncryptedAppTicketDao -import java.io.InputStream -import java.io.OutputStream -import java.util.concurrent.TimeUnit +import timber.log.Timber @AndroidEntryPoint class SteamService : Service(), IChallengeUrlChanged { @@ -255,6 +242,7 @@ class SteamService : Service(), IChallengeUrlChanged { // Connectivity management for Wi-Fi-only downloads private lateinit var connectivityManager: ConnectivityManager private lateinit var networkCallback: ConnectivityManager.NetworkCallback + @Volatile private var isWifiConnected: Boolean = true // Add these as class properties @@ -316,6 +304,7 @@ class SteamService : Service(), IChallengeUrlChanged { @JvmStatic @Volatile var isGameRunning: Boolean = false + @Volatile var isImporting: Boolean = false @@ -478,18 +467,17 @@ class SteamService : Service(), IChallengeUrlChanged { } suspend fun getOwnedAppDlc(appId: Int): Map { - val client = instance?.steamClient ?: return emptyMap() - val accountId = client.steamID?.accountID?.toInt() ?: return emptyMap() + val client = instance?.steamClient ?: return emptyMap() + val accountId = client.steamID?.accountID?.toInt() ?: return emptyMap() val ownedGameIds = getOwnedGames(userSteamId!!.convertToUInt64()).map { it.appId }.toHashSet() - return getAppDlc(appId).filter { (_, depot) -> when { /* Base-game depots always download */ - depot.dlcAppId == INVALID_APP_ID -> true + depot.dlcAppId == INVALID_APP_ID -> true /* Optional DLC depots are skipped */ - depot.optionalDlcId == depot.dlcAppId -> false + depot.optionalDlcId == depot.dlcAppId -> false /* ① licence cache */ instance?.licenseDao?.findLicense(depot.dlcAppId) != null -> true @@ -498,10 +486,10 @@ class SteamService : Service(), IChallengeUrlChanged { instance?.appDao?.findApp(depot.dlcAppId) != null -> true /* ③ owned-games list */ - depot.dlcAppId in ownedGameIds -> true + depot.dlcAppId in ownedGameIds -> true /* ④ final online / cached call */ - else -> false + else -> false } }.toMap() } @@ -544,8 +532,8 @@ class SteamService : Service(), IChallengeUrlChanged { } fun getDownloadableDepots(appId: Int): Map { - val appInfo = getAppInfoOf(appId) ?: return emptyMap() - val ownedDlc = runBlocking { getOwnedAppDlc(appId) } + val appInfo = getAppInfoOf(appId) ?: return emptyMap() + val ownedDlc = runBlocking { getOwnedAppDlc(appId) } val preferredLanguage = PrefManager.containerLanguage // If the game ships any 64-bit depot, prefer those and ignore x86 ones @@ -554,15 +542,21 @@ class SteamService : Service(), IChallengeUrlChanged { return appInfo.depots .asSequence() .filter { (_, depot) -> - if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) + if (depot.manifests.isEmpty() && depot.encryptedManifests.isNotEmpty()) { return@filter false + } // 1. Has something to download - if (depot.manifests.isEmpty() && !depot.sharedInstall) + if (depot.manifests.isEmpty() && !depot.sharedInstall) { return@filter false + } // 2. Supported OS - if (!(depot.osList.contains(OS.windows) || - (!depot.osList.contains(OS.linux) && !depot.osList.contains(OS.macos)))) + if (!( + depot.osList.contains(OS.windows) || + (!depot.osList.contains(OS.linux) && !depot.osList.contains(OS.macos)) + ) + ) { return@filter false + } // 3. 64-bit or indeterminate // Arch selection: allow 64-bit and Unknown always. // Allow 32-bit only when no 64-bit depot exists. @@ -573,11 +567,13 @@ class SteamService : Service(), IChallengeUrlChanged { } if (!archOk) return@filter false // 4. DLC you actually own - if (depot.dlcAppId != INVALID_APP_ID && !ownedDlc.containsKey(depot.depotId)) + if (depot.dlcAppId != INVALID_APP_ID && !ownedDlc.containsKey(depot.depotId)) { return@filter false + } // 5. Language filter - if depot has language, it must match preferred language - if (depot.language.isNotEmpty() && depot.language != preferredLanguage) + if (depot.language.isNotEmpty() && depot.language != preferredLanguage) { return@filter false + } true } @@ -594,10 +590,9 @@ class SteamService : Service(), IChallengeUrlChanged { } fun getAppDirPath(gameId: Int): String { - - val info = getAppInfoOf(gameId) - val appName = getAppDirName(info) - val oldName = info?.name.orEmpty() + val info = getAppInfoOf(gameId) + val appName = getAppDirName(info) + val oldName = info?.name.orEmpty() // Internal first (legacy installs), external second val internalPath = Paths.get(internalAppInstallPath, appName) @@ -621,14 +616,15 @@ class SteamService : Service(), IChallengeUrlChanged { // SteamKit-JVM (most forks) – flags is EnumSet is EnumSet<*> -> { flags.contains(EDepotFileFlag.Executable) || - flags.contains(EDepotFileFlag.CustomExecutable) + flags.contains(EDepotFileFlag.CustomExecutable) } // SteamKit-C# protobuf port – flags is UInt / Int / Long - is Int -> (flags and 0x20) != 0 || (flags and 0x80) != 0 + is Int -> (flags and 0x20) != 0 || (flags and 0x80) != 0 + is Long -> ((flags and 0x20L) != 0L) || ((flags and 0x80L) != 0L) - else -> false + else -> false } /* -------------------------------------------------------------------------- */ @@ -636,18 +632,23 @@ class SteamService : Service(), IChallengeUrlChanged { /* -------------------------------------------------------------------------- */ // Unreal Engine "Shipping" binaries (e.g. Stray-Win64-Shipping.exe) - private val UE_SHIPPING = Regex(""".*-win(32|64)(-shipping)?\.exe$""", - RegexOption.IGNORE_CASE) + private val UE_SHIPPING = Regex( + """.*-win(32|64)(-shipping)?\.exe$""", + RegexOption.IGNORE_CASE, + ) // UE folder hint …/Binaries/Win32|64/… - private val UE_BINARIES = Regex(""".*/binaries/win(32|64)/.*\.exe$""", - RegexOption.IGNORE_CASE) + private val UE_BINARIES = Regex( + """.*/binaries/win(32|64)/.*\.exe$""", + RegexOption.IGNORE_CASE, + ) // Tools / crash-dumpers to push down private val NEGATIVE_KEYWORDS = listOf( "crash", "handler", "viewer", "compiler", "tool", - "setup", "unins", "eac", "launcher", "steam" + "setup", "unins", "eac", "launcher", "steam", ) + /* add near-name helper */ private fun fuzzyMatch(a: String, b: String): Boolean { /* strip digits & punctuation, compare first 5 letters */ @@ -666,27 +667,27 @@ class SteamService : Service(), IChallengeUrlChanged { private fun scoreExe( file: FileData, gameName: String, - hasExeFlag: Boolean + hasExeFlag: Boolean, ): Int { var s = 0 val path = file.fileName.lowercase() // 1️⃣ UE shipping or binaries folder bonus - if (UE_SHIPPING.matches(path)) s += 300 + if (UE_SHIPPING.matches(path)) s += 300 if (UE_BINARIES.containsMatchIn(path)) s += 250 // 2️⃣ root-folder exe bonus - if (!path.contains('/')) s += 200 + if (!path.contains('/')) s += 200 // 3️⃣ filename contains the game / installDir - if (path.contains(gameName) || fuzzyMatch(path, gameName)) s += 100 + if (path.contains(gameName) || fuzzyMatch(path, gameName)) s += 100 // 4️⃣ obvious tool / crash-dumper penalty if (NEGATIVE_KEYWORDS.any { it in path }) s -= 150 - if (GENERIC_NAME.matches(file.fileName)) s -= 200 // ← new + if (GENERIC_NAME.matches(file.fileName)) s -= 200 // ← new // 5️⃣ Executable | CustomExecutable flag - if (hasExeFlag) s += 50 + if (hasExeFlag) s += 50 Timber.i("Score for $path: $s") @@ -696,7 +697,7 @@ class SteamService : Service(), IChallengeUrlChanged { fun FileData.isStub(): Boolean { /* stub detector (same short rules) */ val generic = Regex("^[a-z]\\d{1,3}\\.exe$", RegexOption.IGNORE_CASE) - val bad = listOf("launcher","steam","crash","handler","setup","unins","eac") + val bad = listOf("launcher", "steam", "crash", "handler", "setup", "unins", "eac") val n = fileName.lowercase() val stub = generic.matches(n) || bad.any { it in n } || totalSize < 1_000_000 if (stub) Timber.d("Stub filtered: $fileName size=$totalSize") @@ -706,14 +707,16 @@ class SteamService : Service(), IChallengeUrlChanged { /** select the primary binary */ fun choosePrimaryExe( files: List?, - gameName: String + gameName: String, ): FileData? = files?.maxWithOrNull { a, b -> - val sa = scoreExe(a, gameName, isExecutable(a.flags)) // <- fixed + val sa = scoreExe(a, gameName, isExecutable(a.flags)) // <- fixed val sb = scoreExe(b, gameName, isExecutable(b.flags)) when { - sa != sb -> sa - sb // higher score wins - else -> (a.totalSize - b.totalSize).toInt() // tie-break on size + sa != sb -> sa - sb + + // higher score wins + else -> (a.totalSize - b.totalSize).toInt() // tie-break on size } } @@ -731,8 +734,10 @@ class SteamService : Service(), IChallengeUrlChanged { val installDir = appInfo.config.installDir.ifEmpty { appInfo.name } val depots = appInfo.depots.values.filter { d -> - !d.sharedInstall && (d.osList.isEmpty() || - d.osList.any { it.name.equals("windows", true) || it.name.equals("none", true) }) + !d.sharedInstall && ( + d.osList.isEmpty() || + d.osList.any { it.name.equals("windows", true) || it.name.equals("none", true) } + ) } Timber.i("Depots considered: $depots") @@ -743,7 +748,7 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.i("Launch targets from appinfo: $launchTargets") /* ---------------------------------------------------------- */ - val flagged = mutableListOf>() // (file, depotSize) + val flagged = mutableListOf>() // (file, depotSize) var largestDepotSize = 0L // Use DepotDownloader to fetch manifests @@ -752,9 +757,11 @@ class SteamService : Service(), IChallengeUrlChanged { if (steamClient == null || licenses.isEmpty()) { Timber.w("Cannot fetch manifests: steamClient or licenses not available") // Fallback to last resort - return (getAppInfoOf(appId)?.let { appInfo -> - getWindowsLaunchInfos(appId).firstOrNull() - })?.executable ?: "" + return ( + getAppInfoOf(appId)?.let { appInfo -> + getWindowsLaunchInfos(appId).firstOrNull() + } + )?.executable ?: "" } for (depot in depots) { @@ -771,7 +778,7 @@ class SteamService : Service(), IChallengeUrlChanged { f.fileName.lowercase() in launchTargets && !f.isStub() }?.let { Timber.i("Picked via launch entry: ${it.fileName}") - return it.fileName.replace('\\','/').toString() + return it.fileName.replace('\\', '/').toString() } /* collect for later */ @@ -789,7 +796,7 @@ class SteamService : Service(), IChallengeUrlChanged { val noStubs = pool.filterNot { it.isStub() } if (noStubs.isNotEmpty()) noStubs else pool }, - installDir.lowercase() + installDir.lowercase(), )?.let { Timber.i("Picked via scorer: ${it.fileName}") return it.fileName.replace('\\', '/') @@ -801,14 +808,16 @@ class SteamService : Service(), IChallengeUrlChanged { .maxByOrNull { it.first.totalSize } ?.let { Timber.i("Picked via largest-depot fallback: ${it.first.fileName}") - return it.first.fileName.replace('\\','/').toString() + return it.first.fileName.replace('\\', '/').toString() } /* 4️⃣ last resort */ Timber.w("No executable found; falling back to install dir") - return (getAppInfoOf(appId)?.let { appInfo -> - getWindowsLaunchInfos(appId).firstOrNull() - })?.executable ?: "" + return ( + getAppInfoOf(appId)?.let { appInfo -> + getWindowsLaunchInfos(appId).firstOrNull() + } + )?.executable ?: "" } fun deleteApp(appId: Int): Boolean { @@ -849,9 +858,11 @@ class SteamService : Service(), IChallengeUrlChanged { fun isImageFsInstallable(context: Context, variant: String): Boolean { val imageFs = ImageFs.find(context) if (variant.equals(Container.BIONIC)) { - return File(imageFs.filesDir, "imagefs_bionic.txz").exists() || context.assets.list("")?.contains("imagefs_bionic.txz") == true + return File(imageFs.filesDir, "imagefs_bionic.txz").exists() || + context.assets.list("")?.contains("imagefs_bionic.txz") == true } else { - return File(imageFs.filesDir, "imagefs_gamenative.txz").exists() || context.assets.list("")?.contains("imagefs_gamenative.txz") == true + return File(imageFs.filesDir, "imagefs_gamenative.txz").exists() || + context.assets.list("")?.contains("imagefs_gamenative.txz") == true } } @@ -868,7 +879,7 @@ class SteamService : Service(), IChallengeUrlChanged { suspend fun fetchFile( url: String, dest: File, - onProgress: (Float) -> Unit + onProgress: (Float) -> Unit, ) = withContext(Dispatchers.IO) { val tmp = File(dest.absolutePath + ".part") try { @@ -903,7 +914,7 @@ class SteamService : Service(), IChallengeUrlChanged { fileName: String, dest: File, context: Context, - onProgress: (Float) -> Unit + onProgress: (Float) -> Unit, ) = withContext(Dispatchers.IO) { val primaryUrl = "https://downloads.gamenative.app/$fileName" val fallbackUrl = "https://pub-9fcd5294bd0d4b85a9d73615bf98f3b5.r2.dev/$fileName" @@ -926,7 +937,7 @@ class SteamService : Service(), IChallengeUrlChanged { private inline fun InputStream.copyTo( out: OutputStream, bufferSize: Int = DEFAULT_BUFFER_SIZE, - progress: (Long) -> Unit + progress: (Long) -> Unit, ) { val buf = ByteArray(bufferSize) var bytesRead: Int @@ -946,12 +957,12 @@ class SteamService : Service(), IChallengeUrlChanged { context: Context, ) = parentScope.async { Timber.i("imagefs will be downloaded") - if (variant == Container.BIONIC){ + if (variant == Container.BIONIC) { val dest = File(instance!!.filesDir, "imagefs_bionic.txz") - Timber.d("Downloading imagefs_bionic to " + dest.toString()); + Timber.d("Downloading imagefs_bionic to " + dest.toString()) fetchFileWithFallback("imagefs_bionic.txz", dest, context, onDownloadProgress) } else { - Timber.d("Downloading imagefs_gamenative to " + File(instance!!.filesDir, "imagefs_gamenative.txz")); + Timber.d("Downloading imagefs_gamenative to " + File(instance!!.filesDir, "imagefs_gamenative.txz")) fetchFileWithFallback("imagefs_gamenative.txz", File(instance!!.filesDir, "imagefs_gamenative.txz"), context, onDownloadProgress) } } @@ -963,7 +974,7 @@ class SteamService : Service(), IChallengeUrlChanged { ) = parentScope.async { Timber.i("imagefs will be downloaded") val dest = File(instance!!.filesDir, "imagefs_patches_gamenative.tzst") - Timber.d("Downloading imagefs_patches_gamenative.tzst to " + dest.toString()); + Timber.d("Downloading imagefs_patches_gamenative.tzst to " + dest.toString()) fetchFileWithFallback("imagefs_patches_gamenative.tzst", dest, context, onDownloadProgress) } @@ -971,11 +982,11 @@ class SteamService : Service(), IChallengeUrlChanged { onDownloadProgress: (Float) -> Unit, parentScope: CoroutineScope = CoroutineScope(Dispatchers.IO), context: Context, - fileName: String + fileName: String, ) = parentScope.async { - Timber.i("${fileName} will be downloaded") + Timber.i("$fileName will be downloaded") val dest = File(instance!!.filesDir, fileName) - Timber.d("Downloading ${fileName} to " + dest.toString()); + Timber.d("Downloading $fileName to " + dest.toString()) fetchFileWithFallback(fileName, dest, context, onDownloadProgress) } @@ -986,7 +997,7 @@ class SteamService : Service(), IChallengeUrlChanged { ) = parentScope.async { Timber.i("imagefs will be downloaded") val dest = File(instance!!.filesDir, "steam.tzst") - Timber.d("Downloading steam.tzst to " + dest.toString()); + Timber.d("Downloading steam.tzst to " + dest.toString()) fetchFileWithFallback("steam.tzst", dest, context, onDownloadProgress) } @@ -1062,9 +1073,11 @@ class SteamService : Service(), IChallengeUrlChanged { depotDownloader.addListener(listener) // Create AppItem with only mandatory appId - val appItem = AppItem(appId, + val appItem = AppItem( + appId, installDirectory = getAppDirPath(appId), - depot = entitledDepotIds) + depot = entitledDepotIds, + ) // Add item to downloader depotDownloader.add(appItem) @@ -1139,8 +1152,8 @@ class SteamService : Service(), IChallengeUrlChanged { appId, isDownloaded = true, downloadedDepots = entitledDepotIds, - dlcDepots = ownedDlc.values.map { it.dlcAppId }.distinct() - ) + dlcDepots = ownedDlc.values.map { it.dlcAppId }.distinct(), + ), ) } MarkerUtils.removeMarker(getAppDirPath(appId), Marker.STEAM_DLL_REPLACED) @@ -1160,7 +1173,7 @@ class SteamService : Service(), IChallengeUrlChanged { Toast.makeText( service.applicationContext, service.getString(R.string.download_failed_try_again), - Toast.LENGTH_LONG + Toast.LENGTH_LONG, ).show() } } @@ -1212,8 +1225,6 @@ class SteamService : Service(), IChallengeUrlChanged { } } - - fun getWindowsLaunchInfos(appId: Int): List { return getAppInfoOf(appId)?.let { appInfo -> appInfo.config.launch.filter { launchInfo -> @@ -1267,10 +1278,10 @@ class SteamService : Service(), IChallengeUrlChanged { | processId: ${process.processId} | processIdParent: ${process.processIdParent} | parentIsSteam: ${process.parentIsSteam} - """.trimMargin() + """.trimMargin() } } - """.trimMargin() + """.trimMargin() }, ) @@ -1487,7 +1498,7 @@ class SteamService : Service(), IChallengeUrlChanged { appendLine("}") } - return vdf; + return vdf } private fun login( @@ -1613,17 +1624,25 @@ class SteamService : Service(), IChallengeUrlChanged { try { Timber.i("Logging in via QR.") - instance!!.steamClient?.let { steamClient -> + val service = instance + if (service == null) { + Timber.e("Could not start QR logon: Service not initialized") + val event = SteamEvent.QrAuthEnded(success = false, message = "Service not initialized") + PluviaApp.events.emit(event) + return@withContext + } + + service.steamClient?.let { steamClient -> isWaitingForQRAuth = true val authDetails = AuthSessionDetails().apply { - deviceFriendlyName = SteamUtils.getMachineName(instance!!) + deviceFriendlyName = SteamUtils.getMachineName(service) } val authSession = steamClient.authentication.beginAuthSessionViaQR(authDetails).await() // Steam will periodically refresh the challenge url, this callback allows you to draw a new qr code. - authSession.challengeUrlChanged = instance + authSession.challengeUrlChanged = service val qrEvent = SteamEvent.QrChallengeReceived(authSession.challengeUrl) PluviaApp.events.emit(qrEvent) @@ -1842,15 +1861,15 @@ class SteamService : Service(), IChallengeUrlChanged { ?.apps ?.values ?.firstOrNull() - ?: return@withContext false // nothing returned ⇒ treat as up-to-date + ?: return@withContext false // nothing returned ⇒ treat as up-to-date val remoteSteamApp = remoteAppInfo.keyValues.generateSteamApp() - val localSteamApp = getAppInfoOf(appId) ?: return@withContext true // not cached yet + val localSteamApp = getAppInfoOf(appId) ?: return@withContext true // not cached yet // ── 2. Compare manifest IDs of the depots we actually install. getDownloadableDepots(appId).keys.any { depotId -> val remoteManifest = remoteSteamApp.depots[depotId]?.manifests?.get(branch) - val localManifest = localSteamApp .depots[depotId]?.manifests?.get(branch) + val localManifest = localSteamApp.depots[depotId]?.manifests?.get(branch) // If remote manifest is null, skip this depot (hack for Castle Crashers) if (remoteManifest == null) return@any false remoteManifest?.gid != localManifest?.gid @@ -1866,7 +1885,7 @@ class SteamService : Service(), IChallengeUrlChanged { // Step 1: Get access tokens for all DLC appIds at once val tokens = steamApps.picsGetAccessTokens( appIds = dlcAppIds.toList(), - packageIds = emptyList() + packageIds = emptyList(), ).await() Timber.d("Access tokens response:") @@ -1902,7 +1921,7 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d("Querying PICS chunk with ${chunk.size} apps") val callback = steamApps.picsGetProductInfo( apps = chunk, - packages = emptyList() + packages = emptyList(), ).await() // Collect all appIds that returned results @@ -1933,8 +1952,8 @@ class SteamService : Service(), IChallengeUrlChanged { val clazz = Class.forName("in.dragonbra.javasteam.util.log.LogManager") val field = clazz.getDeclaredField("LOGGERS").apply { isAccessible = true } field.set( - /* obj = */ null, - java.util.concurrent.ConcurrentHashMap() // replaces the HashMap + null, + ConcurrentHashMap(), // replaces the HashMap ) } @@ -1948,7 +1967,7 @@ class SteamService : Service(), IChallengeUrlChanged { val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) isWifiConnected = capabilities?.run { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } == true // Register callback for Wi-Fi connectivity networkCallback = object : ConnectivityManager.NetworkCallback() { @@ -1956,10 +1975,12 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d("Wifi available") isWifiConnected = true } - override fun onCapabilitiesChanged(network: Network, - caps: NetworkCapabilities) { + override fun onCapabilitiesChanged( + network: Network, + caps: NetworkCapabilities, + ) { isWifiConnected = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } override fun onLost(network: Network) { Timber.d("Wifi lost") @@ -2020,9 +2041,9 @@ class SteamService : Service(), IChallengeUrlChanged { it.withConnectionTimeout(60000L) it.withHttpClient( OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) // Time to establish connection - .readTimeout(60, TimeUnit.SECONDS) // Max inactivity between reads - .writeTimeout(30, TimeUnit.SECONDS) // Time for writes + .connectTimeout(10, TimeUnit.SECONDS) // Time to establish connection + .readTimeout(60, TimeUnit.SECONDS) // Max inactivity between reads + .writeTimeout(30, TimeUnit.SECONDS) // Time for writes .build(), ) } @@ -2136,7 +2157,7 @@ class SteamService : Service(), IChallengeUrlChanged { try { steamClient!!.disconnect() - } catch (e: NullPointerException) { + } catch (e: NullPointerException) { // I don't care } catch (e: Exception) { Timber.e(e, "There was an issue when disconnecting:") @@ -2616,15 +2637,15 @@ class SteamService : Service(), IChallengeUrlChanged { Timber.d( "picsGetChangesSince:" + - "\n\tlastChangeNumber: ${changesSince.lastChangeNumber}" + - "\n\tcurrentChangeNumber: ${changesSince.currentChangeNumber}" + - "\n\tisRequiresFullUpdate: ${changesSince.isRequiresFullUpdate}" + - "\n\tisRequiresFullAppUpdate: ${changesSince.isRequiresFullAppUpdate}" + - "\n\tisRequiresFullPackageUpdate: ${changesSince.isRequiresFullPackageUpdate}" + - "\n\tappChangesCount: ${changesSince.appChanges.size}" + - "\n\tpkgChangesCount: ${changesSince.packageChanges.size}", + "\n\tlastChangeNumber: ${changesSince.lastChangeNumber}" + + "\n\tcurrentChangeNumber: ${changesSince.currentChangeNumber}" + + "\n\tisRequiresFullUpdate: ${changesSince.isRequiresFullUpdate}" + + "\n\tisRequiresFullAppUpdate: ${changesSince.isRequiresFullAppUpdate}" + + "\n\tisRequiresFullPackageUpdate: ${changesSince.isRequiresFullPackageUpdate}" + + "\n\tappChangesCount: ${changesSince.appChanges.size}" + + "\n\tpkgChangesCount: ${changesSince.packageChanges.size}", - ) + ) // Process any app changes launch { @@ -2746,8 +2767,8 @@ class SteamService : Service(), IChallengeUrlChanged { callback.results.forEachIndexed { index, picsCallback -> Timber.d( "onPicsProduct: ${index + 1} of ${callback.results.size}" + - "\n\tReceived PICS result of ${picsCallback.apps.size} app(s)." + - "\n\tReceived PICS result of ${picsCallback.packages.size} package(s).", + "\n\tReceived PICS result of ${picsCallback.apps.size} app(s)." + + "\n\tReceived PICS result of ${picsCallback.packages.size} package(s).", ) ensureActive() diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index e19dbc3b7..c0296a40e 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -3,8 +3,6 @@ package app.gamenative.ui import android.content.Context import android.content.Intent import android.widget.Toast -import androidx.activity.OnBackPressedDispatcher -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -17,7 +15,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.BlendMode.Companion.Screen import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.zIndex @@ -43,7 +40,6 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameSource -import app.gamenative.data.PostSyncInfo import app.gamenative.enums.AppTheme import app.gamenative.enums.LoginResult import app.gamenative.enums.PathType @@ -51,13 +47,14 @@ import app.gamenative.enums.SaveLocation import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent import app.gamenative.service.SteamService -import app.gamenative.ui.component.ConnectingServersScreen +import app.gamenative.ui.component.ConnectionStatusBanner import app.gamenative.ui.component.dialog.GameFeedbackDialog import app.gamenative.ui.component.dialog.LoadingDialog import app.gamenative.ui.component.dialog.MessageDialog import app.gamenative.ui.component.dialog.state.GameFeedbackDialogState import app.gamenative.ui.component.dialog.state.MessageDialogState import app.gamenative.ui.components.BootingSplash +import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.enums.DialogType import app.gamenative.ui.enums.Orientation import app.gamenative.ui.model.MainViewModel @@ -68,7 +65,6 @@ import app.gamenative.ui.screen.login.UserLoginScreen import app.gamenative.ui.screen.settings.SettingsScreen import app.gamenative.ui.screen.xserver.XServerScreen import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.utils.ContainerMigrator import app.gamenative.utils.ContainerUtils import app.gamenative.utils.CustomGameScanner import app.gamenative.utils.GameFeedbackUtils @@ -81,14 +77,103 @@ import com.winlator.container.Container import com.winlator.container.ContainerManager import com.winlator.xenvironment.ImageFsInstaller import `in`.dragonbra.javasteam.protobufs.steamclient.SteammessagesClientObjects.ECloudPendingRemoteOperation +import java.util.Date +import java.util.EnumSet +import kotlin.reflect.KFunction2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber -import java.util.Date -import java.util.EnumSet -import kotlin.reflect.KFunction2 + +/** + * Navigates from the LoginUser screen to the target route, popping the login screen from the stack. + * Only navigates if currently on the LoginUser screen. + * + * @param targetRoute The route to navigate to + * @param logTag Optional tag for logging the navigation + */ +private fun NavHostController.navigateFromLoginIfNeeded( + targetRoute: String, + logTag: String = "PluviaMain", +) { + val currentRoute = currentDestination?.route + if (currentRoute == PluviaScreen.LoginUser.route) { + Timber.tag(logTag).i("Navigating from LoginUser to $targetRoute") + navigate(targetRoute) { + popUpTo(PluviaScreen.LoginUser.route) { + inclusive = true + } + } + } +} + +/** + * Result of resolving a game app ID to determine the correct format and installation status. + */ +private sealed class GameResolutionResult { + /** + * Game was resolved successfully. + * @param finalAppId The resolved app ID (may be different from original if it's a custom game) + * @param gameId The extracted numeric game ID + * @param isSteamInstalled Whether this is an installed Steam game + * @param isCustomGame Whether this is a custom game + */ + data class Success( + val finalAppId: String, + val gameId: Int, + val isSteamInstalled: Boolean, + val isCustomGame: Boolean, + ) : GameResolutionResult() + + /** + * Game was not found - neither installed as Steam game nor as custom game. + * @param gameId The extracted numeric game ID + * @param originalAppId The original app ID that was passed in + */ + data class NotFound( + val gameId: Int, + val originalAppId: String, + ) : GameResolutionResult() +} + +/** + * Resolves an app ID to determine the correct game source and format. + * Checks both Steam installation status and custom game registry. + * + * @param appId The app ID to resolve (format: "STEAM_" or "CUSTOM_GAME_") + * @return GameResolutionResult indicating success with resolved ID or not found + */ +private fun resolveGameAppId(appId: String): GameResolutionResult { + val gameId = ContainerUtils.extractGameIdFromContainerId(appId) + val isSteamInstalled = SteamService.isAppInstalled(gameId) + + val customGamePath = if (!isSteamInstalled) { + CustomGameScanner.findCustomGameById(gameId) + } else { + null + } + + // If neither Steam installed nor custom game found + if (!isSteamInstalled && customGamePath == null) { + return GameResolutionResult.NotFound(gameId, appId) + } + + // Determine the final appId to use + val isCustomGame = customGamePath != null && !isSteamInstalled + val finalAppId = if (isCustomGame) { + "${GameSource.CUSTOM_GAME.name}_$gameId" + } else { + appId + } + + return GameResolutionResult.Success( + finalAppId = finalAppId, + gameId = gameId, + isSteamInstalled = isSteamInstalled, + isCustomGame = isCustomGame, + ) +} @Composable fun PluviaMain( @@ -143,43 +228,39 @@ fun PluviaMain( is MainViewModel.MainUiEvent.ExternalGameLaunch -> { Timber.i("[PluviaMain]: Received ExternalGameLaunch UI event for app ${event.appId}") - // Extract game ID from appId (format: "STEAM_" or "CUSTOM_GAME_") - val gameId = ContainerUtils.extractGameIdFromContainerId(event.appId) + when (val resolution = resolveGameAppId(event.appId)) { + is GameResolutionResult.Success -> { + Timber.i("[PluviaMain]: Using appId: ${resolution.finalAppId} (original: ${event.appId}, isSteamInstalled: ${resolution.isSteamInstalled}, isCustomGame: ${resolution.isCustomGame})") - // First check if it's a Steam game and if it's installed - val isSteamInstalled = SteamService.isAppInstalled(gameId) - - // If not installed as Steam game, check if it's a custom game - val customGamePath = if (!isSteamInstalled) { - CustomGameScanner.findCustomGameById(gameId) - } else { - null - } + viewModel.setLaunchedAppId(resolution.finalAppId) + viewModel.setBootToContainer(false) + preLaunchApp( + context = context, + appId = resolution.finalAppId, + setLoadingDialogVisible = viewModel::setLoadingDialogVisible, + setLoadingProgress = viewModel::setLoadingDialogProgress, + setLoadingMessage = viewModel::setLoadingDialogMessage, + setMessageDialogState = setMessageDialogState, + onSuccess = viewModel::launchApp, + ) + } - // Determine the final appId to use - val finalAppId = if (customGamePath != null && !isSteamInstalled) { - "${GameSource.CUSTOM_GAME.name}_$gameId" - } else { - event.appId + is GameResolutionResult.NotFound -> { + val appName = SteamService.getAppInfoOf(resolution.gameId)?.name ?: "App ${event.appId}" + Timber.w("[PluviaMain]: Game not installed: $appName (${event.appId})") + msgDialogState = MessageDialogState( + visible = true, + type = DialogType.SYNC_FAIL, + title = context.getString(R.string.game_not_installed_title), + message = context.getString(R.string.game_not_installed_message, appName), + dismissBtnText = context.getString(R.string.ok), + ) + } } - - Timber.i("[PluviaMain]: Using appId: $finalAppId (original: ${event.appId}, isSteamInstalled: $isSteamInstalled, customGamePath: ${customGamePath != null})") - - viewModel.setLaunchedAppId(finalAppId) - viewModel.setBootToContainer(false) - preLaunchApp( - context = context, - appId = finalAppId, - setLoadingDialogVisible = viewModel::setLoadingDialogVisible, - setLoadingProgress = viewModel::setLoadingDialogProgress, - setLoadingMessage = viewModel::setLoadingDialogMessage, - setMessageDialogState = setMessageDialogState, - onSuccess = viewModel::launchApp, - ) } MainViewModel.MainUiEvent.OnBackPressed -> { - if (SteamService.isGameRunning){ + if (SteamService.isGameRunning) { gameBackAction?.invoke() ?: run { navController.popBackStack() } } else if (hasBack) { // TODO: check if back leads to log out and present confidence modal @@ -190,7 +271,9 @@ fun PluviaMain( } MainViewModel.MainUiEvent.OnLoggedOut -> { - // Pop stack and go back to login. + // Clear persisted route so next login starts fresh from Home + viewModel.clearPersistedRoute() + // Pop stack and go back to login navController.popBackStack( route = PluviaScreen.LoginUser.route, inclusive = false, @@ -203,86 +286,70 @@ fun PluviaMain( LoginResult.Success -> { if (MainActivity.hasPendingLaunchRequest()) { MainActivity.consumePendingLaunchRequest()?.let { launchRequest -> - Timber.tag("IntentLaunch").i("Processing pending launch request for app ${launchRequest.appId} (user is now logged in)") - - // Extract game ID from appId (format: "STEAM_" or "CUSTOM_GAME_") - val gameId = ContainerUtils.extractGameIdFromContainerId(launchRequest.appId) - - // First check if it's a Steam game and if it's installed - val isSteamInstalled = SteamService.isAppInstalled(gameId) - - // If not installed as Steam game, check if it's a custom game - val customGamePath = if (!isSteamInstalled) { - CustomGameScanner.findCustomGameById(gameId) - } else { - null - } - - // If neither Steam installed nor custom game found, show error - if (!isSteamInstalled && customGamePath == null) { - val appName = SteamService.getAppInfoOf(gameId)?.name ?: "App ${launchRequest.appId}" - Timber.tag("IntentLaunch").w("Game not installed: $appName (${launchRequest.appId})") - - // Show error message - msgDialogState = MessageDialogState( - visible = true, - type = DialogType.SYNC_FAIL, - title = context.getString(R.string.game_not_installed_title), - message = context.getString(R.string.game_not_installed_message, appName), - dismissBtnText = context.getString(R.string.ok), - ) - return@let - } - - // If it's a custom game, update the appId to use CUSTOM_GAME format - val finalAppId = if (customGamePath != null && !isSteamInstalled) { - "${GameSource.CUSTOM_GAME.name}_$gameId" - } else { - launchRequest.appId - } + Timber.tag("IntentLaunch") + .i("Processing pending launch request for app ${launchRequest.appId} (user is now logged in)") + + when (val resolution = resolveGameAppId(launchRequest.appId)) { + is GameResolutionResult.NotFound -> { + val appName = SteamService.getAppInfoOf(resolution.gameId)?.name ?: "App ${launchRequest.appId}" + Timber.tag("IntentLaunch").w("Game not installed: $appName (${launchRequest.appId})") + msgDialogState = MessageDialogState( + visible = true, + type = DialogType.SYNC_FAIL, + title = context.getString(R.string.game_not_installed_title), + message = context.getString(R.string.game_not_installed_message, appName), + dismissBtnText = context.getString(R.string.ok), + ) + return@let + } - // Update launchRequest with the correct appId if it was changed - val updatedLaunchRequest = if (finalAppId != launchRequest.appId) { - launchRequest.copy(appId = finalAppId) - } else { - launchRequest - } + is GameResolutionResult.Success -> { + // Update launchRequest with the resolved appId if it changed + val updatedLaunchRequest = if (resolution.finalAppId != launchRequest.appId) { + launchRequest.copy(appId = resolution.finalAppId) + } else { + launchRequest + } - if (updatedLaunchRequest.containerConfig != null) { - IntentLaunchManager.applyTemporaryConfigOverride( - context, - updatedLaunchRequest.appId, - updatedLaunchRequest.containerConfig, - ) - Timber.tag("IntentLaunch").i("Applied container config override for app ${updatedLaunchRequest.appId}") - } + if (updatedLaunchRequest.containerConfig != null) { + IntentLaunchManager.applyTemporaryConfigOverride( + context, + updatedLaunchRequest.appId, + updatedLaunchRequest.containerConfig, + ) + Timber.tag("IntentLaunch") + .i("Applied container config override for app ${updatedLaunchRequest.appId}") + } - if (navController.currentDestination?.route != PluviaScreen.Home.route) { - navController.navigate(PluviaScreen.Home.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = false + // Navigate to Home if not already there (for pending launch requests) + if (navController.currentDestination?.route != PluviaScreen.Home.route) { + navController.navigate(PluviaScreen.Home.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = false + } + } } + + viewModel.setLaunchedAppId(updatedLaunchRequest.appId) + viewModel.setBootToContainer(false) + preLaunchApp( + context = context, + appId = updatedLaunchRequest.appId, + setLoadingDialogVisible = viewModel::setLoadingDialogVisible, + setLoadingProgress = viewModel::setLoadingDialogProgress, + setLoadingMessage = viewModel::setLoadingDialogMessage, + setMessageDialogState = setMessageDialogState, + onSuccess = viewModel::launchApp, + ) } } - - viewModel.setLaunchedAppId(updatedLaunchRequest.appId) - viewModel.setBootToContainer(false) - preLaunchApp( - context = context, - appId = updatedLaunchRequest.appId, - setLoadingDialogVisible = viewModel::setLoadingDialogVisible, - setLoadingProgress = viewModel::setLoadingDialogProgress, - setLoadingMessage = viewModel::setLoadingDialogMessage, - setMessageDialogState = setMessageDialogState, - onSuccess = viewModel::launchApp, - ) } - } - else if (PluviaApp.xEnvironment == null) { - Timber.i("Navigating to library") - navController.navigate(PluviaScreen.Home.route) + } else if (PluviaApp.xEnvironment == null) { + // Only navigate if currently on LoginUser screen + val targetRoute = viewModel.getPersistedRoute() ?: PluviaScreen.Home.route + navController.navigateFromLoginIfNeeded(targetRoute, "LogonEnded") - // Check for update first + // Show dialogs regardless of navigation val currentUpdateInfo = updateInfo if (currentUpdateInfo != null) { viewModel.setAnnoyingDialogShown(true) @@ -293,7 +360,7 @@ fun PluviaMain( message = context.getString( R.string.main_update_available_message, currentUpdateInfo.versionName, - currentUpdateInfo.releaseNotes?.let { "\n\n$it" } ?: "" + currentUpdateInfo.releaseNotes?.let { "\n\n$it" } ?: "", ), confirmBtnText = context.getString(R.string.main_update_button), dismissBtnText = context.getString(R.string.main_later_button), @@ -366,10 +433,14 @@ fun PluviaMain( } PluviaApp.events.emit(AndroidEvent.StartOrientator) } else { - navController.removeOnDestinationChangedListener(PluviaApp.onDestinationChangedListener!!) + PluviaApp.onDestinationChangedListener?.let { + navController.removeOnDestinationChangedListener(it) + } } - navController.addOnDestinationChangedListener(PluviaApp.onDestinationChangedListener!!) + PluviaApp.onDestinationChangedListener?.let { + navController.addOnDestinationChangedListener(it) + } } // TODO merge to VM? @@ -396,18 +467,28 @@ fun PluviaMain( LaunchedEffect(Unit) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { - if (!state.isSteamConnected && !isConnecting && !SteamService.isGameRunning) { - Timber.d("[PluviaMain]: Steam not connected - attempt") + // Only attempt reconnection if not already connected/connecting and not in offline mode + val shouldAttemptReconnect = !state.isSteamConnected && + !isConnecting && + !SteamService.isGameRunning && + state.connectionState != ConnectionState.OFFLINE_MODE + + if (shouldAttemptReconnect) { + Timber.d("[PluviaMain]: Steam not connected - attempting reconnection") isConnecting = true + viewModel.startConnecting() // Update ViewModel state for UI context.startForegroundService(Intent(context, SteamService::class.java)) } - if (SteamService.isLoggedIn && !SteamService.isGameRunning && state.currentScreen == PluviaScreen.LoginUser) { - navController.navigate(PluviaScreen.Home.route) + // Handle navigation when already logged in (e.g., app resumed with active session) + // Only navigate if currently on LoginUser screen to avoid disrupting user's current view + if (SteamService.isLoggedIn && !SteamService.isGameRunning) { + val targetRoute = viewModel.getPersistedRoute() ?: PluviaScreen.Home.route + navController.navigateFromLoginIfNeeded(targetRoute, "ResumeSession") } } } - // Listen for connection state changes + // Listen for connection state changes - reset local isConnecting flag LaunchedEffect(state.isSteamConnected) { if (state.isSteamConnected) { isConnecting = false @@ -448,42 +529,6 @@ fun PluviaMain( } } - // Timeout if stuck in connecting state for 10 seconds so that its not in loading state forever - LaunchedEffect(isConnecting) { - if (isConnecting) { - Timber.d("Started connecting, will timeout in 10s") - delay(10000) - Timber.d("Timeout reached, isSteamConnected=${state.isSteamConnected}") - if (!state.isSteamConnected) { - isConnecting = false - } - } - } - - // Show loading or error UI as appropriate - when { - isConnecting -> { - PluviaTheme( - isDark = when (state.appTheme) { - AppTheme.AUTO -> isSystemInDarkTheme() - AppTheme.DAY -> false - AppTheme.NIGHT -> true - AppTheme.AMOLED -> true - }, - isAmoled = state.appTheme == AppTheme.AMOLED, - style = state.paletteStyle, - ) { - ConnectingServersScreen( - onContinueOffline = { - isConnecting = false - navController.navigate(PluviaScreen.Home.route + "?offline=true") - }, - ) - } - return - } - } - val onDismissRequest: (() -> Unit)? val onDismissClick: (() -> Unit)? val onConfirmClick: (() -> Unit)? @@ -501,6 +546,7 @@ fun PluviaMain( setMessageDialogState(MessageDialogState(false)) } } + DialogType.SUPPORT -> { onConfirmClick = { uriHandler.openUri(Constants.Misc.KO_FI_LINK) @@ -516,10 +562,10 @@ fun PluviaMain( onActionClick = { val shareIntent = Intent().apply { action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, "Check out GameNative - play your PC Steam games on Android, with full support for cloud saves!\nhttps://gamenative.app\nJoin the community: https://discord.gg/2hKv4VfZfE") + putExtra(Intent.EXTRA_TEXT, context.getString(R.string.main_share_text)) type = "text/plain" } - context.startActivity(Intent.createChooser(shareIntent, "Share GameNative")) + context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.main_share))) } } @@ -738,7 +784,7 @@ fun PluviaMain( versionName = updateInfo.versionName, onProgress = { progress -> viewModel.setLoadingDialogProgress(progress) - } + }, ) viewModel.setLoadingDialogVisible(false) @@ -803,7 +849,13 @@ fun PluviaMain( state = gameFeedbackState, onStateChange = { gameFeedbackState = it }, onSubmit = { feedbackState -> - Timber.d("GameFeedback: onSubmit called with rating=${feedbackState.rating}, tags=${feedbackState.selectedTags}, text=${feedbackState.feedbackText.take(20)}") + Timber.d( + "GameFeedback: onSubmit called with rating=${feedbackState.rating}, tags=${feedbackState.selectedTags}, text=${ + feedbackState.feedbackText.take( + 20, + ) + }", + ) try { // Get the container for the app val appId = feedbackState.appId @@ -858,27 +910,42 @@ fun PluviaMain( BootingSplash( visible = state.showBootingSplash, text = state.bootingSplashText, - onBootCompleted = { - viewModel.setShowBootingSplash(false) - }, ) } + // Connection status banner (overlay) + if (state.currentScreen != PluviaScreen.LoginUser) { + Box(modifier = Modifier.zIndex(5f)) { + ConnectionStatusBanner( + connectionState = state.connectionState, + connectionMessage = state.connectionMessage, + timeoutSeconds = state.connectionTimeoutSeconds, + onContinueOffline = { + viewModel.continueOffline() + }, + onRetry = { + viewModel.retryConnection() + context.startForegroundService(Intent(context, SteamService::class.java)) + }, + ) + } + } + NavHost( navController = navController, startDestination = PluviaScreen.LoginUser.route, ) { - /** Login **/ /** Login **/ composable(route = PluviaScreen.LoginUser.route) { UserLoginScreen( + connectionState = state.connectionState, + onRetryConnection = viewModel::retryConnection, onContinueOffline = { navController.navigate(PluviaScreen.Home.route + "?offline=true") }, ) } /** Library, Downloads, Friends **/ - /** Library, Downloads, Friends **/ composable( route = PluviaScreen.Home.route + "?offline={offline}", deepLinks = listOf(navDeepLink { uriPattern = "pluvia://home" }), @@ -925,8 +992,6 @@ fun PluviaMain( ) } - /** Full Screen Chat **/ - /** Full Screen Chat **/ composable( route = "chat/{id}", @@ -947,8 +1012,6 @@ fun PluviaMain( ) } - /** Game Screen **/ - /** Game Screen **/ composable(route = PluviaScreen.XServer.route) { XServerScreen( @@ -962,7 +1025,7 @@ fun PluviaMain( CoroutineScope(Dispatchers.Main).launch { val currentRoute = navController.currentBackStackEntry ?.destination - ?.route // ← this is the screen’s route string + ?.route // ← this is the screen’s route string if (currentRoute == PluviaScreen.XServer.route) { navController.popBackStack() @@ -981,8 +1044,6 @@ fun PluviaMain( ) } - /** Settings **/ - /** Settings **/ composable(route = PluviaScreen.Settings.route) { SettingsScreen( @@ -1045,7 +1106,9 @@ fun preLaunchApp( context = context, ).await() } - if (container.containerVariant.equals(Container.GLIBC) && !SteamService.isFileInstallable(context, "imagefs_patches_gamenative.tzst")) { + if (container.containerVariant.equals(Container.GLIBC) && + !SteamService.isFileInstallable(context, "imagefs_patches_gamenative.tzst") + ) { setLoadingMessage("Downloading Wine") SteamService.downloadImageFsPatches( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, @@ -1053,31 +1116,37 @@ fun preLaunchApp( context = context, ).await() } else { - if (container.wineVersion.contains("proton-9.0-arm64ec") && !SteamService.isFileInstallable(context, "proton-9.0-arm64ec.txz")) { + if (container.wineVersion.contains("proton-9.0-arm64ec") && + !SteamService.isFileInstallable(context, "proton-9.0-arm64ec.txz") + ) { setLoadingMessage("Downloading arm64ec Proton") SteamService.downloadFile( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "proton-9.0-arm64ec.txz" + "proton-9.0-arm64ec.txz", ).await() - } else if (container.wineVersion.contains("proton-9.0-x86_64") && !SteamService.isFileInstallable(context, "proton-9.0-x86_64.txz")) { + } else if (container.wineVersion.contains("proton-9.0-x86_64") && + !SteamService.isFileInstallable(context, "proton-9.0-x86_64.txz") + ) { setLoadingMessage("Downloading x86_64 Proton") SteamService.downloadFile( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "proton-9.0-x86_64.txz" + "proton-9.0-x86_64.txz", ).await() } } - if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "experimental-drm.tzst")) { + if (!container.isUseLegacyDRM && !container.isLaunchRealSteam && + !SteamService.isFileInstallable(context, "experimental-drm.tzst") + ) { setLoadingMessage("Downloading extras") SteamService.downloadFile( onDownloadProgress = { setLoadingProgress(it / 1.0f) }, this, context = context, - "experimental-drm.tzst" + "experimental-drm.tzst", ).await() } if (container.isLaunchRealSteam && !SteamService.isFileInstallable(context, "steam.tzst")) { @@ -1088,10 +1157,11 @@ fun preLaunchApp( context = context, ).await() } - val loadingMessage = if (container.containerVariant.equals(Container.GLIBC)) + val loadingMessage = if (container.containerVariant.equals(Container.GLIBC)) { context.getString(R.string.main_installing_glibc) - else + } else { context.getString(R.string.main_installing_bionic) + } setLoadingMessage(loadingMessage) val imageFsInstallSuccess = ImageFsInstaller.installIfNeededFuture(context, context.assets, container) { progress -> @@ -1122,7 +1192,9 @@ fun preLaunchApp( ) return@launch } - } catch (_: Exception) { /* ignore persona read errors */ } + } catch (_: Exception) { + /* ignore persona read errors */ + } // For Custom Games, bypass Steam Cloud operations entirely and proceed to launch if (isCustomGame) { @@ -1157,7 +1229,7 @@ fun preLaunchApp( message = context.getString( R.string.main_save_conflict_message, Date(postSyncInfo.localTimestamp).toString(), - Date(postSyncInfo.remoteTimestamp).toString() + Date(postSyncInfo.remoteTimestamp).toString(), ), dismissBtnText = context.getString(R.string.main_keep_local), confirmBtnText = context.getString(R.string.main_keep_remote), @@ -1200,6 +1272,7 @@ fun preLaunchApp( ) } } + SyncResult.UnknownFail, SyncResult.DownloadFail, SyncResult.UpdateFail, @@ -1218,11 +1291,11 @@ fun preLaunchApp( SyncResult.PendingOperations -> { Timber.i( "Pending remote operations:${ - postSyncInfo.pendingRemoteOperations.map { pro -> + postSyncInfo.pendingRemoteOperations.joinToString("\n") { pro -> "\n\tmachineName: ${pro.machineName}" + "\n\ttimestamp: ${Date(pro.timeLastUpdated * 1000L)}" + "\n\toperation: ${pro.operation}" - }.joinToString("\n") + } }", ) if (postSyncInfo.pendingRemoteOperations.size == 1) { @@ -1242,7 +1315,7 @@ fun preLaunchApp( R.string.main_upload_in_progress_message, gameName, pro.machineName, - dateStr + dateStr, ), dismissBtnText = context.getString(R.string.ok), ), @@ -1259,7 +1332,7 @@ fun preLaunchApp( R.string.main_pending_upload_message, gameName, pro.machineName, - dateStr + dateStr, ), confirmBtnText = context.getString(R.string.main_play_anyway), dismissBtnText = context.getString(R.string.cancel), @@ -1277,7 +1350,7 @@ fun preLaunchApp( R.string.main_app_running_other_device, pro.machineName, gameName, - dateStr + dateStr, ), confirmBtnText = context.getString(R.string.main_play_anyway), dismissBtnText = context.getString(R.string.cancel), diff --git a/app/src/main/java/app/gamenative/ui/component/CompatibilityBadge.kt b/app/src/main/java/app/gamenative/ui/component/CompatibilityBadge.kt new file mode 100644 index 000000000..94f98a7dc --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/CompatibilityBadge.kt @@ -0,0 +1,179 @@ +package app.gamenative.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Memory +import androidx.compose.material.icons.rounded.QuestionMark +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.ui.theme.PluviaTheme + +/** + * Badge displaying game compatibility status. + * + * Can be displayed as: + * - Icon-only (for grid views) - compact circular badge + * - Icon + label (for list views) - pill-shaped badge with text + * + * @param status The compatibility status to display + * @param modifier Modifier for the badge + * @param showLabel Whether to show the text label (true for list view, false for grid) + */ +@Composable +fun CompatibilityBadge( + status: GameCompatibilityStatus, + modifier: Modifier = Modifier, + showLabel: Boolean = false, +) { + val badgeStyle = getBadgeStyle(status) + + if (showLabel) { + PillBadge( + modifier = modifier, + icon = badgeStyle.icon, + backgroundColor = badgeStyle.backgroundColor, + iconTint = badgeStyle.iconTint, + label = badgeStyle.labelResId, + ) + } else { + IconBadge( + modifier = modifier, + icon = badgeStyle.icon, + backgroundColor = badgeStyle.backgroundColor, + iconTint = badgeStyle.iconTint, + contentDescription = badgeStyle.labelResId, + ) + } +} + +/** + * Style configuration for a compatibility badge. + */ +private data class BadgeStyle( + val icon: ImageVector, + val backgroundColor: Color, + val iconTint: Color, + val labelResId: Int, +) + +/** + * Gets the badge style for a given compatibility status. + */ +@Composable +private fun getBadgeStyle(status: GameCompatibilityStatus): BadgeStyle { + val colors = PluviaTheme.colors + return when (status) { + GameCompatibilityStatus.COMPATIBLE -> BadgeStyle( + icon = Icons.Rounded.Verified, + backgroundColor = colors.compatibilityGoodBackground.copy(alpha = 0.9f), + iconTint = colors.compatibilityGood, + labelResId = R.string.library_compatible, + ) + + GameCompatibilityStatus.GPU_COMPATIBLE -> BadgeStyle( + icon = Icons.Rounded.Memory, + backgroundColor = colors.compatibilityPartialBackground.copy(alpha = 0.9f), + iconTint = colors.compatibilityPartial, + labelResId = R.string.library_gpu_compatible, + ) + + GameCompatibilityStatus.UNKNOWN -> BadgeStyle( + icon = Icons.Rounded.QuestionMark, + backgroundColor = colors.compatibilityUnknownBackground.copy(alpha = 0.8f), + iconTint = colors.compatibilityUnknown, + labelResId = R.string.library_compatibility_unknown, + ) + + GameCompatibilityStatus.NOT_COMPATIBLE -> BadgeStyle( + icon = Icons.Rounded.Close, + backgroundColor = colors.compatibilityBadBackground.copy(alpha = 0.9f), + iconTint = colors.compatibilityBad, + labelResId = R.string.library_not_compatible, + ) + } +} + +/** + * Pill-shaped badge with icon and label (for list views). + */ +@Composable +private fun PillBadge( + modifier: Modifier, + icon: ImageVector, + backgroundColor: Color, + iconTint: Color, + label: Int, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringResource(label), + style = MaterialTheme.typography.labelSmall, + color = iconTint, + fontWeight = FontWeight.Medium, + ) + } +} + +/** + * Circular icon-only badge (for grid views). + */ +@Composable +private fun IconBadge( + modifier: Modifier, + icon: ImageVector, + backgroundColor: Color, + iconTint: Color, + contentDescription: Int, +) { + Box( + modifier = modifier + .size(24.dp) + .shadow(4.dp, CircleShape) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = stringResource(contentDescription), + tint = iconTint, + modifier = Modifier.size(14.dp), + ) + } +} + diff --git a/app/src/main/java/app/gamenative/ui/component/ConnectingServersScreen.kt b/app/src/main/java/app/gamenative/ui/component/ConnectingServersScreen.kt deleted file mode 100644 index 805d9aac7..000000000 --- a/app/src/main/java/app/gamenative/ui/component/ConnectingServersScreen.kt +++ /dev/null @@ -1,61 +0,0 @@ -package app.gamenative.ui.component - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import app.gamenative.R -import app.gamenative.ui.theme.PluviaTheme - -@Composable -fun ConnectingServersScreen( - onContinueOffline: () -> Unit -) { - Surface( - color = Color.Black, - modifier = Modifier.fillMaxSize() - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = stringResource(R.string.connect_to_remote_server), - color = Color.White, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(bottom = 24.dp) - ) - - CircularProgressIndicator( - modifier = Modifier - .size(64.dp) - .padding(bottom = 24.dp), - color = MaterialTheme.colorScheme.primary - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Button(modifier = Modifier.padding(vertical = 16.dp), onClick = onContinueOffline) { - Text(stringResource(R.string.continue_offline)) - } - } - } -} - -@Preview -@Composable -private fun Preview_ConnectSteamScreen() { - PluviaTheme { - ConnectingServersScreen( - onContinueOffline = {} - ) - } -} diff --git a/app/src/main/java/app/gamenative/ui/component/ConnectionStatusBanner.kt b/app/src/main/java/app/gamenative/ui/component/ConnectionStatusBanner.kt new file mode 100644 index 000000000..956843e63 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/ConnectionStatusBanner.kt @@ -0,0 +1,260 @@ +package app.gamenative.ui.component + +import androidx.compose.animation.AnimatedVisibility +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.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.ui.enums.ConnectionState +import app.gamenative.ui.theme.PluviaTheme + +private const val TIMEOUT_SHOW_OFFLINE_OPTION_SECONDS = 5 + +@Composable +fun ConnectionStatusBanner( + connectionState: ConnectionState, + connectionMessage: String?, + timeoutSeconds: Int, + onContinueOffline: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = connectionState == ConnectionState.CONNECTING || + connectionState == ConnectionState.DISCONNECTED + + AnimatedVisibility( + visible = isVisible, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + modifier = modifier + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.Transparent, + shadowElevation = 4.dp, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), + ) + ) + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ConnectionIcon(connectionState) + + Text( + text = when (connectionState) { + ConnectionState.CONNECTING -> { + connectionMessage ?: stringResource(R.string.connection_reconnecting) + } + ConnectionState.DISCONNECTED -> { + connectionMessage ?: stringResource(R.string.connection_disconnected) + } + else -> "" + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + + if (connectionState == ConnectionState.CONNECTING && timeoutSeconds >= TIMEOUT_SHOW_OFFLINE_OPTION_SECONDS) { + Text( + text = "${timeoutSeconds}s", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + when (connectionState) { + ConnectionState.CONNECTING -> { + if (timeoutSeconds >= TIMEOUT_SHOW_OFFLINE_OPTION_SECONDS) { + TextButton( + onClick = onContinueOffline, + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + text = stringResource(R.string.continue_offline), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + ConnectionState.DISCONNECTED -> { + TextButton( + onClick = onRetry, + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + text = stringResource(R.string.connection_retry), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + } + else -> {} + } + } + } + } + } +} + +@Composable +private fun ConnectionIcon(connectionState: ConnectionState) { + val infiniteTransition = rememberInfiniteTransition(label = "connectionIcon") + + when (connectionState) { + ConnectionState.CONNECTING -> { + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(18.dp) + .rotate(rotation) + ) + } + } + ConnectionState.DISCONNECTED -> { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.CloudOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + } + } + else -> {} + } +} + +@Preview +@Composable +private fun Preview_ConnectionStatusBanner_Connecting() { + PluviaTheme { + ConnectionStatusBanner( + connectionState = ConnectionState.CONNECTING, + connectionMessage = "Reconnecting to Steam...", + timeoutSeconds = 3, + onContinueOffline = {}, + onRetry = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_ConnectionStatusBanner_Connecting_WithTimeout() { + PluviaTheme { + ConnectionStatusBanner( + connectionState = ConnectionState.CONNECTING, + connectionMessage = "Reconnecting to Steam...", + timeoutSeconds = 8, + onContinueOffline = {}, + onRetry = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_ConnectionStatusBanner_Disconnected() { + PluviaTheme { + ConnectionStatusBanner( + connectionState = ConnectionState.DISCONNECTED, + connectionMessage = "Connection lost", + timeoutSeconds = 0, + onContinueOffline = {}, + onRetry = {}, + ) + } +} + +@Preview +@Composable +private fun Preview_ConnectionStatusBanner_Connected() { + PluviaTheme { + // Should not be visible + ConnectionStatusBanner( + connectionState = ConnectionState.CONNECTED, + connectionMessage = null, + timeoutSeconds = 0, + onContinueOffline = {}, + onRetry = {}, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/GamepadActionBar.kt b/app/src/main/java/app/gamenative/ui/component/GamepadActionBar.kt new file mode 100644 index 000000000..f11764b03 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/GamepadActionBar.kt @@ -0,0 +1,205 @@ +package app.gamenative.ui.component + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.ui.icons.InputIcons +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.ui.util.shouldShowGamepadUI + +// Icons from https://kenney.nl/assets/input-prompts (CC0 License) +enum class GamepadButton(@field:DrawableRes val iconRes: Int) { + A(InputIcons.Xbox.buttonColorA), + B(InputIcons.Xbox.buttonColorB), + X(InputIcons.Xbox.buttonColorX), + Y(InputIcons.Xbox.buttonColorY), + LB(InputIcons.Xbox.lb), + RB(InputIcons.Xbox.rb), + LT(InputIcons.Xbox.lt), + RT(InputIcons.Xbox.rt), + START(InputIcons.Xbox.start), + SELECT(InputIcons.Xbox.select), + DPAD(InputIcons.Xbox.dpad), + DPAD_UP(InputIcons.Xbox.dpadUp), + DPAD_DOWN(InputIcons.Xbox.dpadDown), + DPAD_LEFT(InputIcons.Xbox.dpadLeft), + DPAD_RIGHT(InputIcons.Xbox.dpadRight), +} + +data class GamepadAction( + val button: GamepadButton, + @get:StringRes val labelResId: Int, + val onClick: (() -> Unit)? = null, +) + +@Composable +private fun GamepadButtonHint( + action: GamepadAction, + modifier: Modifier = Modifier, +) { + val clickableModifier = if (action.onClick != null) { + modifier.clickable(onClick = action.onClick) + } else { + modifier + } + + val label = stringResource(action.labelResId) + + Row( + modifier = clickableModifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Image( + painter = painterResource(action.button.iconRes), + contentDescription = label, + modifier = Modifier.size(28.dp), + ) + + Text( + text = label, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +fun GamepadActionBar( + actions: List, + modifier: Modifier = Modifier, + visible: Boolean = true, +) { + val showGamepadUI = shouldShowGamepadUI() + + AnimatedVisibility( + visible = visible && actions.isNotEmpty() && showGamepadUI, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + fadeIn(), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessHigh, + ), + ) + fadeOut(), + modifier = modifier, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + ), + ), + ), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .navigationBarsPadding(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.85f), + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + actions.forEach { action -> + GamepadButtonHint(action = action) + } + } + } + } + } +} + +object LibraryActions { + val select = GamepadAction(GamepadButton.A, R.string.action_select) + val back = GamepadAction(GamepadButton.B, R.string.back) + val options = GamepadAction(GamepadButton.START, R.string.options) + val search = GamepadAction(GamepadButton.Y, R.string.search) + val addGame = GamepadAction(GamepadButton.X, R.string.action_add_game) + val refresh = GamepadAction(GamepadButton.RB, R.string.action_refresh) + val play = GamepadAction(GamepadButton.A, R.string.run_app) + val details = GamepadAction(GamepadButton.X, R.string.action_details) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + device = "spec:width=1920px,height=1080px,dpi=440,orientation=landscape", +) +@Composable +private fun Preview_GamepadActionBar() { + val context = LocalContext.current + PrefManager.init(context) + PluviaTheme { + Surface(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + ) { + GamepadActionBar( + actions = listOf( + LibraryActions.select, + LibraryActions.options, + LibraryActions.search, + LibraryActions.addGame, + ), + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/OptionListItem.kt b/app/src/main/java/app/gamenative/ui/component/OptionListItem.kt new file mode 100644 index 000000000..43d8589cb --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/OptionListItem.kt @@ -0,0 +1,327 @@ +package app.gamenative.ui.component + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.SortByAlpha +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.ui.theme.PluviaTheme + +@Composable +fun OptionListItem( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + focusRequester: FocusRequester = remember { FocusRequester() }, + showCheckmark: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.02f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "itemScale" + ) + + val backgroundColor by animateColorAsState( + targetValue = when { + isFocused && selected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) + isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + selected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + else -> Color.Transparent + }, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "bgColor" + ) + + val borderColor by animateColorAsState( + targetValue = if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "borderColor" + ) + + val borderWidth by animateDpAsState( + targetValue = if (isFocused) 2.dp else 0.dp, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "borderWidth" + ) + + val contentColor by animateColorAsState( + targetValue = when { + isFocused -> MaterialTheme.colorScheme.onSurface + selected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "contentColor" + ) + + Box( + modifier = modifier + .scale(scale) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .then( + if (isFocused) { + Modifier.border(borderWidth, borderColor, RoundedCornerShape(12.dp)) + } else Modifier + ) + .focusRequester(focusRequester) + .selectable( + selected = selected, + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.width(14.dp)) + } + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + modifier = Modifier.weight(1f) + ) + + if (showCheckmark && selected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +@Composable +fun OptionRadioItem( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.02f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "radioScale" + ) + + val backgroundColor by animateColorAsState( + targetValue = when { + isFocused && selected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.25f) + isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + selected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + else -> Color.Transparent + }, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "radioBgColor" + ) + + val borderColor by animateColorAsState( + targetValue = if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "radioBorderColor" + ) + + val contentColor by animateColorAsState( + targetValue = when { + isFocused -> MaterialTheme.colorScheme.onSurface + selected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "radioContentColor" + ) + + val radioIndicatorColor by animateColorAsState( + targetValue = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "radioIndicatorColor" + ) + + Box( + modifier = modifier + .scale(scale) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .then( + if (isFocused) { + Modifier.border(2.dp, borderColor, RoundedCornerShape(12.dp)) + } else Modifier + ) + .focusRequester(focusRequester) + .selectable( + selected = selected, + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(20.dp) + .border(2.dp, radioIndicatorColor, CircleShape), + contentAlignment = Alignment.Center + ) { + if (selected) { + Box( + modifier = Modifier + .size(10.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + ) + } + } + + Spacer(modifier = Modifier.width(14.dp)) + + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + ) + } + } +} + +@Composable +fun OptionSectionHeader( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + letterSpacing = MaterialTheme.typography.labelMedium.letterSpacing * 1.5f, + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_OptionListItem() { + PluviaTheme { + Surface(color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + OptionSectionHeader(text = "Sort By") + OptionRadioItem( + text = "Installed First", + selected = true, + onClick = {}, + icon = Icons.Default.Download + ) + OptionRadioItem( + text = "Name (A-Z)", + selected = false, + onClick = {}, + icon = Icons.Default.SortByAlpha + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OptionSectionHeader(text = "Filter By Type") + OptionListItem( + text = "Games", + selected = true, + onClick = {}, + ) + OptionListItem( + text = "Applications", + selected = false, + onClick = {}, + ) + OptionListItem( + text = "Tools", + selected = true, + onClick = {}, + ) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt new file mode 100644 index 000000000..aadcec30f --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -0,0 +1,367 @@ +package app.gamenative.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.ui.util.adaptivePanelWidth + +object QuickMenuAction { + const val KEYBOARD = 1 + const val INPUT_CONTROLS = 2 + const val EXIT_GAME = 3 + const val EDIT_CONTROLS = 4 + const val EDIT_PHYSICAL_CONTROLLER = 5 +} + +data class QuickMenuItem( + val id: Int, + val icon: ImageVector, + val labelResId: Int, + val accentColor: Color = Color.Unspecified, + val enabled: Boolean = true, +) + +@Composable +fun QuickMenu( + isVisible: Boolean, + onDismiss: () -> Unit, + onItemSelected: (Int) -> Unit, + hasPhysicalController: Boolean = false, + modifier: Modifier = Modifier, +) { + val menuItems = buildList { + add(QuickMenuItem( + id = QuickMenuAction.KEYBOARD, + icon = Icons.Default.Keyboard, + labelResId = R.string.keyboard, + accentColor = PluviaTheme.colors.accentCyan, + )) + add(QuickMenuItem( + id = QuickMenuAction.INPUT_CONTROLS, + icon = Icons.Default.TouchApp, + labelResId = R.string.input_controls, + accentColor = PluviaTheme.colors.accentPurple, + )) + add(QuickMenuItem( + id = QuickMenuAction.EDIT_CONTROLS, + icon = Icons.Default.Edit, + labelResId = R.string.edit_controls, + accentColor = PluviaTheme.colors.accentSuccess, + )) + if (hasPhysicalController) { + add(QuickMenuItem( + id = QuickMenuAction.EDIT_PHYSICAL_CONTROLLER, + icon = Icons.Default.Gamepad, + labelResId = R.string.edit_physical_controller, + accentColor = PluviaTheme.colors.accentWarning, + )) + } + add(QuickMenuItem( + id = QuickMenuAction.EXIT_GAME, + icon = Icons.AutoMirrored.Filled.ExitToApp, + labelResId = R.string.exit_game, + accentColor = PluviaTheme.colors.accentDanger, + )) + } + + val firstItemFocusRequester = remember { FocusRequester() } + + BackHandler(enabled = isVisible) { + onDismiss() + } + + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(150)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + } + + AnimatedVisibility( + visible = isVisible, + enter = slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ), + exit = slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium + ) + ), + modifier = Modifier.align(Alignment.CenterEnd) + ) { + Surface( + modifier = Modifier + .width(adaptivePanelWidth(280.dp)) + .fillMaxHeight(), + shape = RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + shadowElevation = 24.dp, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.quick_menu_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurface + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.quick_menu_back), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .focusGroup() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + menuItems.forEachIndexed { index, item -> + QuickMenuItemRow( + item = item, + onClick = { + onItemSelected(item.id) + onDismiss() + }, + focusRequester = if (index == 0) firstItemFocusRequester else null, + ) + } + } + } + } + } + } + + LaunchedEffect(isVisible) { + if (isVisible) { + try { + firstItemFocusRequester.requestFocus() + } catch (_: Exception) { + // Focus request may fail if composition is not ready + } + } + } +} + +@Composable +private fun QuickMenuItemRow( + item: QuickMenuItem, + onClick: () -> Unit, + focusRequester: FocusRequester? = null, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val isEnabled = item.enabled + + val accentColor = if (item.accentColor != Color.Unspecified) { + item.accentColor + } else { + MaterialTheme.colorScheme.primary + } + + val disabledAlpha = 0.4f + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .then( + if (isFocused && isEnabled) { + Modifier.background( + brush = Brush.horizontalGradient( + colors = listOf( + accentColor.copy(alpha = 0.15f), + accentColor.copy(alpha = 0.05f), + ) + ) + ) + } else Modifier + ) + .then( + if (focusRequester != null) { + Modifier.focusRequester(focusRequester) + } else Modifier + ) + .selectable( + selected = isFocused, + enabled = isEnabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick + ) + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + when { + !isEnabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + isFocused -> accentColor.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = item.icon, + contentDescription = null, + tint = when { + !isEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha) + isFocused -> accentColor + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(22.dp) + ) + } + + Text( + text = stringResource(item.labelResId), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (isFocused && isEnabled) FontWeight.SemiBold else FontWeight.Normal + ), + color = when { + !isEnabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) + isFocused -> accentColor + else -> MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.weight(1f) + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun Preview_QuickMenu() { + PluviaTheme { + Box(modifier = Modifier.fillMaxSize()) { + QuickMenu( + isVisible = true, + onDismiss = {}, + onItemSelected = {}, + hasPhysicalController = false, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun Preview_QuickMenu_WithController() { + PluviaTheme { + Box(modifier = Modifier.fillMaxSize()) { + QuickMenu( + isVisible = true, + onDismiss = {}, + onItemSelected = {}, + hasPhysicalController = true, + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/Scrollbar.kt b/app/src/main/java/app/gamenative/ui/component/Scrollbar.kt new file mode 100644 index 000000000..eb0527ff0 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/Scrollbar.kt @@ -0,0 +1,334 @@ +package app.gamenative.ui.component + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** Fallback item height in pixels when no visible items available for measurement */ +private const val FALLBACK_ITEM_HEIGHT_PX = 100f + +/** + * Draggable scrollbar for LazyVerticalGrid + */ +@Composable +fun Scrollbar( + listState: LazyGridState, + modifier: Modifier = Modifier, + thumbColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f), + thumbWidthCollapsed: Dp = 4.dp, + thumbWidthExpanded: Dp = 10.dp, + thumbMinHeightDp: Dp = 48.dp, + hideDelay: Long = 1500L, + content: @Composable BoxScope.() -> Unit, +) { + val scope = rememberCoroutineScope() + + // Track visibility and interaction state + var isVisible by remember { mutableStateOf(false) } + var isDragging by remember { mutableStateOf(false) } + var isTouchScrolling by remember { mutableStateOf(false) } + var containerHeight by remember { mutableFloatStateOf(0f) } + + // Drag state - when dragging, thumb follows gesture directly instead of list state + var dragProgress by remember { mutableFloatStateOf(0f) } + // Cache grid parameters at drag start to prevent recalculation during drag + var dragColumnsCount by remember { mutableStateOf(1) } + var dragTotalRows by remember { mutableStateOf(1) } + var dragTotalItems by remember { mutableStateOf(0) } + + // Read layout info directly + val layoutInfo = listState.layoutInfo + val totalItemsCount = layoutInfo.totalItemsCount + val visibleItemsInfo = layoutInfo.visibleItemsInfo + val viewportHeight = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset + val isScrollInProgress = listState.isScrollInProgress + + // Calculate smooth scroll progress using pixel-level precision + // For grids, account for multiple columns per row + val scrollProgress = remember( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + totalItemsCount, + viewportHeight, + ) { + if (totalItemsCount == 0 || visibleItemsInfo.isEmpty()) { + 0f + } else { + // Estimate column count by counting items sharing the same Y offset + val firstRowY = visibleItemsInfo.first().offset.y + val columnsCount = visibleItemsInfo.count { it.offset.y == firstRowY }.coerceAtLeast(1) + + // Calculate average row height from visible items + val avgRowHeight = if (visibleItemsInfo.isNotEmpty()) { + visibleItemsInfo.sumOf { it.size.height } / visibleItemsInfo.size.toFloat() + } else { + FALLBACK_ITEM_HEIGHT_PX + } + + // Calculate total rows and current row + val totalRows = (totalItemsCount + columnsCount - 1) / columnsCount + val currentRow = listState.firstVisibleItemIndex / columnsCount + + val estimatedTotalHeight = totalRows * avgRowHeight + val estimatedScrollableHeight = (estimatedTotalHeight - viewportHeight).coerceAtLeast(1f) + + // Calculate current scroll position in pixels (row-based) + val currentScrollOffset = currentRow * avgRowHeight + listState.firstVisibleItemScrollOffset + + (currentScrollOffset / estimatedScrollableHeight).coerceIn(0f, 1f) + } + } + + // Calculate thumb height ratio based on viewport vs estimated total content height + // For grids, we need to account for multiple columns per row + val thumbHeightRatio = if (totalItemsCount == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0) { + 1f + } else { + // Estimate column count by counting items sharing the same Y offset (first row) + val firstRowY = visibleItemsInfo.first().offset.y + val columnsCount = visibleItemsInfo.count { it.offset.y == firstRowY }.coerceAtLeast(1) + + // Calculate average row height from visible items + val avgItemHeight = visibleItemsInfo.sumOf { it.size.height } / visibleItemsInfo.size.toFloat() + + // Calculate total rows (ceiling division) + val totalRows = (totalItemsCount + columnsCount - 1) / columnsCount + val estimatedTotalHeight = totalRows * avgItemHeight + + if (estimatedTotalHeight <= 0f) { + 1f + } else { + (viewportHeight.toFloat() / estimatedTotalHeight).coerceIn(0.05f, 1f) + } + } + + val isExpanded = isDragging || isTouchScrolling + + // Animate width + val thumbWidth by animateDpAsState( + targetValue = if (isExpanded) thumbWidthExpanded else thumbWidthCollapsed, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "thumbWidth", + ) + + // Animate visibility + val alpha by animateFloatAsState( + targetValue = if (isVisible || isDragging) 1f else 0f, + animationSpec = tween(durationMillis = 200), + label = "scrollbarAlpha", + ) + + // Animate grab handle opacity + val grabHandleAlpha by animateFloatAsState( + targetValue = if (isExpanded) 1f else 0f, + animationSpec = tween(durationMillis = 150), + label = "grabHandleAlpha", + ) + + // Track touch scrolling state + LaunchedEffect(isScrollInProgress) { + if (isScrollInProgress && !isDragging) { + isTouchScrolling = true + } else if (!isScrollInProgress) { + delay(300) + isTouchScrolling = false + } + } + + // Show scrollbar when scrolling + LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { + if (totalItemsCount > visibleItemsInfo.size) { + isVisible = true + delay(hideDelay) + if (!isDragging && !isTouchScrolling) { + isVisible = false + } + } + } + + val showScrollbar = totalItemsCount > visibleItemsInfo.size + + Box(modifier = modifier.fillMaxSize()) { + content() + + if (showScrollbar && alpha > 0f) { + val density = androidx.compose.ui.platform.LocalDensity.current + val thumbMinHeightPx = with(density) { thumbMinHeightDp.toPx() } + val thumbHeightPx = (containerHeight * thumbHeightRatio).coerceAtLeast(thumbMinHeightPx) + val maxOffset = (containerHeight - thumbHeightPx).coerceAtLeast(0f) + val thumbHeightDp = with(density) { thumbHeightPx.toDp() } + + // When dragging, thumb follows gesture directly; otherwise follows list state + val effectiveProgress = if (isDragging) dragProgress else scrollProgress + val thumbOffset = effectiveProgress * maxOffset + + // Pre-calculate grid info for scroll operations + val columnsCount = if (visibleItemsInfo.isNotEmpty()) { + val firstRowY = visibleItemsInfo.first().offset.y + visibleItemsInfo.count { it.offset.y == firstRowY }.coerceAtLeast(1) + } else { + 1 + } + val totalRows = (totalItemsCount + columnsCount - 1) / columnsCount + + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(24.dp) + .padding(end = 4.dp) + .alpha(alpha) + .onSizeChanged { containerHeight = it.height.toFloat() } + .pointerInput(Unit) { + detectTapGestures { offset -> + val targetProgress = (offset.y / containerHeight).coerceIn(0f, 1f) + val targetRow = (targetProgress * (totalRows - 1)).roundToInt() + val targetIndex = (targetRow * columnsCount).coerceIn(0, totalItemsCount - 1) + scope.launch { + listState.animateScrollToItem(targetIndex) + } + } + } + .pointerInput(totalItemsCount, columnsCount, totalRows) { + detectDragGestures( + onDragStart = { + // Cache grid parameters at drag start + dragColumnsCount = columnsCount + dragTotalRows = totalRows + dragTotalItems = totalItemsCount + dragProgress = scrollProgress + isDragging = true + isVisible = true + }, + onDragEnd = { + isDragging = false + scope.launch { + delay(hideDelay) + if (!isTouchScrolling) { + isVisible = false + } + } + }, + onDragCancel = { + isDragging = false + scope.launch { + delay(hideDelay) + if (!isTouchScrolling) { + isVisible = false + } + } + }, + onDrag = { change, dragAmount -> + change.consume() + // Update drag progress directly from gesture + val deltaProgress = dragAmount.y / maxOffset.coerceAtLeast(1f) + dragProgress = (dragProgress + deltaProgress).coerceIn(0f, 1f) + + // Use cached grid parameters for stable scroll calculations + val maxRow = (dragTotalRows - 1).coerceAtLeast(0) + val targetRow = (dragProgress * maxRow).roundToInt() + val targetIndex = (targetRow * dragColumnsCount).coerceIn(0, dragTotalItems - 1) + + // Scroll synchronously to avoid race conditions + scope.launch { + listState.scrollToItem(targetIndex.coerceAtLeast(0)) + } + }, + ) + }, + ) { + // Track + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(thumbWidth) + .clip(RoundedCornerShape(50)) + .background(trackColor), + ) + + // Scrollbar thumb + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset { IntOffset(0, thumbOffset.roundToInt()) } + .width(thumbWidth) + .height(thumbHeightDp) + .clip(RoundedCornerShape(50)) + .background( + brush = Brush.verticalGradient( + colors = listOf( + thumbColor, + thumbColor.copy(alpha = thumbColor.alpha * 0.8f), + ), + ), + ), + contentAlignment = Alignment.Center, + ) { + // Grab handle lines (only visible when expanded) + if (grabHandleAlpha > 0f) { + Column( + modifier = Modifier.alpha(grabHandleAlpha), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + repeat(3) { + Box( + modifier = Modifier + .width(6.dp) + .height(1.5.dp) + .clip(RoundedCornerShape(50)) + .background(Color.White.copy(alpha = 0.7f)), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/SupportersDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/SupportersDialog.kt index 60a5dba15..7562dbed3 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/SupportersDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/SupportersDialog.kt @@ -1,13 +1,10 @@ package app.gamenative.ui.component.dialog -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.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,7 +15,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.VolunteerActivism import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -49,13 +46,24 @@ fun SupportersDialog( var supporters by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } + var hasError by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isLoading = true - val data = withContext(Dispatchers.IO) { - fetchKofiSupporters(PluviaApp.supabase) + hasError = false + try { + // Check if supabase is initialized before accessing + if (PluviaApp.isSupabaseInitialized()) { + val data = withContext(Dispatchers.IO) { + fetchKofiSupporters(PluviaApp.supabase) + } + supporters = data + } else { + hasError = true + } + } catch (e: Exception) { + hasError = true } - supporters = data isLoading = false } @@ -77,43 +85,43 @@ fun SupportersDialog( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Art Credits Section Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Brush, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.supporters_art_credits), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } - Divider(modifier = Modifier.padding(vertical = 4.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = stringResource(R.string.supporters_app_icon), - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) Text( text = stringResource(R.string.supporters_alt_icon), - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } @@ -123,48 +131,61 @@ fun SupportersDialog( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) + .padding(vertical = 8.dp), ) { Text( text = stringResource(R.string.supporters_loading), style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 16.dp) + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } else if (hasError) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.supporters_error), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(vertical = 16.dp), ) } } else { if (members.isNotEmpty()) { Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Person, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.supporters_members), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } - Divider(modifier = Modifier.padding(vertical = 4.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { members.forEach { sup -> Text( text = (sup.name ?: stringResource(R.string.supporters_anonymous)), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } } @@ -175,35 +196,35 @@ fun SupportersDialog( if (oneOffs.isNotEmpty()) { Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.VolunteerActivism, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.supporters_supporters), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } - Divider(modifier = Modifier.padding(vertical = 4.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { oneOffs.forEach { sup -> Text( text = (sup.name ?: stringResource(R.string.supporters_anonymous)), - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } @@ -215,17 +236,17 @@ fun SupportersDialog( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = 16.dp), ) { Text( text = stringResource(R.string.supporters_none), - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } } } - } + }, ) } @@ -245,43 +266,43 @@ fun SupportersDialogPreview() { modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Art Credits Section Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Brush, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.supporters_art_credits), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } - Divider(modifier = Modifier.padding(vertical = 4.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = stringResource(R.string.supporters_app_icon), - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) Text( text = stringResource(R.string.supporters_alt_icon), - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } @@ -290,38 +311,38 @@ fun SupportersDialogPreview() { // Members Section Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Person, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.supporters_members), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } - Divider(modifier = Modifier.padding(vertical = 4.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Supporter 1", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) Text( text = "Supporter 2", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } @@ -330,44 +351,44 @@ fun SupportersDialogPreview() { // Supporters Section Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { Column( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.VolunteerActivism, contentDescription = null, - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.supporters_supporters), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } - Divider(modifier = Modifier.padding(vertical = 4.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "One-off Supporter 1", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) Text( text = "One-off Supporter 2", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } } } } - } + }, ) } } diff --git a/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt b/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt index b502cc19d..b90231aa8 100644 --- a/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt +++ b/app/src/main/java/app/gamenative/ui/components/BootingSplash.kt @@ -5,8 +5,10 @@ import androidx.compose.animation.Crossfade import androidx.compose.animation.core.* import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,34 +20,76 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import app.gamenative.ui.theme.PluviaTheme -import kotlinx.coroutines.delay +import kotlin.math.sin import kotlin.random.Random +import kotlinx.coroutines.delay @Composable fun BootingSplash( visible: Boolean = true, - text: String = "Booting...", - onBootCompleted: () -> Unit = {} + text: String = "Initializing...", + progress: Float = -1f, // -1 for indeterminate, 0-1 for determinate ) { - // Tailwind-style “animate-pulse”: opacity 0.3 → 0.5 → 0.3 - val pulseAlpha by rememberInfiniteTransition(label = "pulse") - .animateFloat( - initialValue = 0.25f, - targetValue = 0.65f, - animationSpec = infiniteRepeatable( - animation = tween(3000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse - ), - label = "alpha" - ) + val infiniteTransition = rememberInfiniteTransition(label = "bootSplash") + + // Logo glow pulse animation + val glowAlpha by infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 0.8f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = EaseInOutCubic), + repeatMode = RepeatMode.Reverse, + ), + label = "glowPulse", + ) + + // Logo scale breathing effect + val logoScale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.02f, + animationSpec = infiniteRepeatable( + animation = tween(3000, easing = EaseInOutSine), + repeatMode = RepeatMode.Reverse, + ), + label = "logoScale", + ) + + // Shimmer position for progress bar + val shimmerPosition by infiniteTransition.animateFloat( + initialValue = -0.3f, + targetValue = 1.3f, + animationSpec = infiniteRepeatable( + animation = tween(1500, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer", + ) + + // Ambient particle animation + val particlePhase by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(20000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "particlePhase", + ) + // Tips rotation val tips = remember { listOf( "Booting may take a few minutes on first launch", @@ -74,87 +118,133 @@ fun BootingSplash( ) } - // Start at a random tip, then rotate while visible var tipIndex by remember { mutableStateOf(if (tips.isNotEmpty()) Random.nextInt(tips.size) else 0) } LaunchedEffect(visible, tips) { while (visible && tips.isNotEmpty()) { - delay(10000) + delay(8000) tipIndex = (tipIndex + 1) % tips.size } } AnimatedVisibility( visible = visible, - enter = fadeIn(animationSpec = tween(durationMillis = 99)), - exit = fadeOut() + enter = fadeIn(animationSpec = tween(durationMillis = 400)), + exit = fadeOut(animationSpec = tween(durationMillis = 300)), ) { Box( modifier = Modifier .fillMaxSize() - .background(Color.Black), - contentAlignment = Alignment.Center + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.background, + PluviaTheme.colors.surfacePanel, + MaterialTheme.colorScheme.background, + ), + ), + ), + contentAlignment = Alignment.Center, ) { - // Gradient overlay (drawn first, so it sits behind the text) - Box( - modifier = Modifier - .fillMaxSize() - .alpha(pulseAlpha) - .background( - Brush.linearGradient( - colors = listOf( - Color(0xFFA21CAF), // purple start - Color.Black, // black centre 1 - Color.Black, // black centre 2 - Color.Black, - Color.Black, - Color(0xFF06B6D4) // cyan end - ) - ) + AmbientParticles(phase = particlePhase) + + // Main content + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Spacer(modifier = Modifier.weight(0.4f)) + + // Logo with glow effect + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.scale(logoScale), + ) { + // Glow layer (blurred behind) + Text( + text = "GameNative", + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + letterSpacing = 2.sp, + ), + color = PluviaTheme.colors.accentCyan.copy(alpha = glowAlpha * 0.6f), + modifier = Modifier + .blur(20.dp) + .alpha(glowAlpha), ) - ) - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "GameNative", - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - brush = Brush.horizontalGradient( - colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary - ) - ) + // Main logo text + Text( + text = "GameNative", + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 36.sp, + letterSpacing = 2.sp, + shadow = Shadow( + color = PluviaTheme.colors.accentCyan.copy(alpha = 0.5f), + offset = Offset(0f, 0f), + blurRadius = 20f, + ), + brush = Brush.horizontalGradient( + colors = listOf( + PluviaTheme.colors.accentCyan, + PluviaTheme.colors.accentPurple, + PluviaTheme.colors.accentPink, + ), + ), + ), ) + } + + Spacer(modifier = Modifier.height(48.dp)) + + ProgressBar( + progress = progress, + shimmerPosition = shimmerPosition, + modifier = Modifier + .fillMaxWidth(0.7f) + .height(4.dp), ) - Spacer(modifier = Modifier.height(16.dp)) + + Spacer(modifier = Modifier.height(20.dp)) + + // Status text Text( text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium, + letterSpacing = 1.sp, + ), + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, ) + Spacer(modifier = Modifier.weight(0.3f)) + // Tips section if (tips.isNotEmpty()) { - Spacer(modifier = Modifier.height(24.dp)) Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 48.dp), + contentAlignment = Alignment.Center, ) { Crossfade( targetState = tipIndex, - animationSpec = tween(durationMillis = 600), - label = "tipCrossfade" + animationSpec = tween(durationMillis = 800, easing = EaseInOutCubic), + label = "tipCrossfade", ) { idx -> Text( text = tips[idx], - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), + style = MaterialTheme.typography.bodySmall.copy( + lineHeight = 20.sp, + ), + color = PluviaTheme.colors.borderDefault, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) + .padding(horizontal = 24.dp), ) } } @@ -164,10 +254,142 @@ fun BootingSplash( } } -@Preview(name = "BootingSplash") +@Composable +private fun ProgressBar( + progress: Float, + shimmerPosition: Float, + modifier: Modifier = Modifier, +) { + val isIndeterminate = progress < 0f + val actualProgress = if (isIndeterminate) 1f else progress.coerceIn(0f, 1f) + + Box( + modifier = modifier + .clip(RoundedCornerShape(2.dp)) + .background(PluviaTheme.colors.borderDefault.copy(alpha = 0.3f)), + ) { + // Progress fill with gradient + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(actualProgress) + .clip(RoundedCornerShape(2.dp)) + .background( + Brush.horizontalGradient( + colors = listOf( + PluviaTheme.colors.accentCyan, + PluviaTheme.colors.accentPurple, + PluviaTheme.colors.accentPink, + ), + ), + ), + ) + + // Shimmer overlay + if (isIndeterminate || progress > 0f) { + Canvas( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(2.dp)), + ) { + val shimmerWidth = size.width * 0.3f + val shimmerStart = (shimmerPosition * size.width) - shimmerWidth + val shimmerEnd = shimmerStart + shimmerWidth + + drawRect( + brush = Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + Color.White.copy(alpha = 0.4f), + Color.Transparent, + ), + startX = shimmerStart, + endX = shimmerEnd, + ), + ) + } + } + } +} + +@Composable +private fun AmbientParticles( + phase: Float, + modifier: Modifier = Modifier, +) { + val particleColor = PluviaTheme.colors.accentCyan + + val particles = remember { + List(12) { + ParticleData( + baseX = Random.nextFloat(), + baseY = Random.nextFloat(), + size = Random.nextFloat() * 3f + 1f, + speed = Random.nextFloat() * 0.5f + 0.5f, + phaseOffset = Random.nextFloat() * 360f, + ) + } + } + + Canvas(modifier = modifier.fillMaxSize()) { + particles.forEach { particle -> + val animatedPhase = (phase + particle.phaseOffset) * particle.speed + val radians = Math.toRadians(animatedPhase.toDouble()) + + val offsetX = (sin(radians) * 30).toFloat() + val offsetY = (sin(radians * 0.7) * 20).toFloat() + + val x = particle.baseX * size.width + offsetX + val y = particle.baseY * size.height + offsetY + + // Pulsing alpha based on phase + val alpha = (0.15f + 0.15f * sin(radians * 2).toFloat()).coerceIn(0f, 0.3f) + + drawCircle( + color = particleColor.copy(alpha = alpha), + radius = particle.size.dp.toPx(), + center = Offset(x, y), + ) + } + } +} + +private data class ParticleData( + val baseX: Float, + val baseY: Float, + val size: Float, + val speed: Float, + val phaseOffset: Float, +) + +@Preview(name = "BootingSplash - Indeterminate") @Composable fun BootingSplashPreview() { - PluviaTheme { - BootingSplash(visible = true) - } + PluviaTheme { + BootingSplash(visible = true) + } +} + +@Preview(name = "BootingSplash - 50% Progress") +@Composable +fun BootingSplashProgressPreview() { + PluviaTheme { + BootingSplash( + visible = true, + text = "Loading game files...", + progress = 0.5f, + ) + } +} + +@Preview(name = "BootingSplash - Dark", device = "spec:width=1920px,height=1080px,dpi=440") +@Composable +fun BootingSplashLandscapePreview() { + PluviaTheme { + BootingSplash( + visible = true, + text = "Preparing container...", + progress = -1f, + ) + } } diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt index c6cac8364..a6e160717 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -4,6 +4,8 @@ import app.gamenative.PrefManager import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.LibraryItem import app.gamenative.ui.enums.AppFilter +import app.gamenative.ui.enums.LibraryTab +import app.gamenative.ui.enums.SortOption import java.util.EnumSet data class LibraryState( @@ -24,14 +26,23 @@ data class LibraryState( // App Source filters (Steam / Custom Games) val showSteamInLibrary: Boolean = PrefManager.showSteamInLibrary, val showCustomGamesInLibrary: Boolean = PrefManager.showCustomGamesInLibrary, - + // Loading state for skeleton loaders val isLoading: Boolean = false, - + // Refresh counter that increments when custom game images are fetched // Used to trigger UI recomposition to show newly downloaded images val imageRefreshCounter: Long = 0, - + // Compatibility status map: game name -> compatibility status val compatibilityMap: Map = emptyMap(), + + // Sort option for the library + val currentSortOption: SortOption = PrefManager.librarySortOption, + + // Options panel open state + val isOptionsPanelOpen: Boolean = false, + + // Current library tab for quick filter access + val currentTab: LibraryTab = LibraryTab.ALL, ) diff --git a/app/src/main/java/app/gamenative/ui/data/MainState.kt b/app/src/main/java/app/gamenative/ui/data/MainState.kt index b7eecc9c1..90a1129ce 100644 --- a/app/src/main/java/app/gamenative/ui/data/MainState.kt +++ b/app/src/main/java/app/gamenative/ui/data/MainState.kt @@ -1,6 +1,7 @@ package app.gamenative.ui.data import app.gamenative.enums.AppTheme +import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.screen.PluviaScreen import com.materialkolor.PaletteStyle @@ -20,4 +21,10 @@ data class MainState( val bootToContainer: Boolean = false, val showBootingSplash: Boolean = false, val bootingSplashText: String = "Booting...", + + // Connection state for background reconnection + // Default to DISCONNECTED - service will start and set to CONNECTING + val connectionState: ConnectionState = ConnectionState.DISCONNECTED, + val connectionMessage: String? = null, + val connectionTimeoutSeconds: Int = 0, ) diff --git a/app/src/main/java/app/gamenative/ui/data/UserLoginState.kt b/app/src/main/java/app/gamenative/ui/data/UserLoginState.kt index 678901fac..a15b33750 100644 --- a/app/src/main/java/app/gamenative/ui/data/UserLoginState.kt +++ b/app/src/main/java/app/gamenative/ui/data/UserLoginState.kt @@ -9,7 +9,6 @@ data class UserLoginState( val rememberSession: Boolean = false, val twoFactorCode: String = "", - val isSteamConnected: Boolean = false, val isLoggingIn: Boolean = false, val loginResult: LoginResult = LoginResult.Failed, @@ -22,22 +21,4 @@ data class UserLoginState( val qrCode: String? = null, val isQrFailed: Boolean = false, val lastTwoFactorMethod: String? = null, -) { - override fun toString(): String { - return "UserLoginState(" + - "username='$username', " + - "password='$password', " + - "rememberSession=$rememberSession, " + - "twoFactorCode='$twoFactorCode', " + - "isSteamConnected=$isSteamConnected, " + - "isLoggingIn=$isLoggingIn, " + - "loginResult=$loginResult, " + - "loginScreen=$loginScreen, " + - "previousCodeIncorrect=$previousCodeIncorrect, " + - "email=$email, " + - "qrCode=$qrCode, " + - "isQrFailed=$isQrFailed, " + - "lastTwoFactorMethod=$lastTwoFactorMethod" + - ")" - } -} +) diff --git a/app/src/main/java/app/gamenative/ui/data/XServerState.kt b/app/src/main/java/app/gamenative/ui/data/XServerState.kt index 8c8367c02..f0e2a3b19 100644 --- a/app/src/main/java/app/gamenative/ui/data/XServerState.kt +++ b/app/src/main/java/app/gamenative/ui/data/XServerState.kt @@ -1,12 +1,12 @@ package app.gamenative.ui.data -import androidx.compose.runtime.saveable.mapSaver import com.winlator.container.Container -import com.winlator.core.DXVKHelper import com.winlator.core.KeyValueSet import com.winlator.core.WineInfo +import com.winlator.inputcontrols.ControlElement data class XServerState( + // Wine/Container configuration var winStarted: Boolean = false, val dxwrapper: String = Container.DEFAULT_DXWRAPPER, val dxwrapperConfig: KeyValueSet? = null, @@ -15,33 +15,16 @@ data class XServerState( val graphicsDriver: String = Container.DEFAULT_GRAPHICS_DRIVER, val graphicsDriverVersion: String = "", val audioDriver: String = Container.DEFAULT_AUDIO_DRIVER, -) { - companion object { - val Saver = mapSaver( - save = { state -> - mapOf( - "winStarted" to state.winStarted, - "dxwrapper" to state.dxwrapper, - "dxwrapperConfig" to (state.dxwrapperConfig?.data ?: ""), - "screenSize" to state.screenSize, - "wineInfo" to state.wineInfo, - "graphicsDriver" to state.graphicsDriver, - "graphicsDriverVersion" to state.graphicsDriverVersion, - "audioDriver" to state.audioDriver, - ) - }, - restore = { map -> - XServerState( - winStarted = map["winStarted"] as Boolean, - dxwrapper = map["dxwrapper"] as String, - dxwrapperConfig = DXVKHelper.parseConfig(map["dxwrapperConfig"] as String), - screenSize = map["screenSize"] as String, - wineInfo = map["wineInfo"] as WineInfo, - graphicsDriver = map["graphicsDriver"] as String, - graphicsDriverVersion = map["graphicsDriverVersion"] as String? ?: "", - audioDriver = map["audioDriver"] as String, - ) - }, - ) - } -} + + // UI Control State + val areControlsVisible: Boolean = false, + val isEditMode: Boolean = false, + val showQuickMenu: Boolean = false, + val showPhysicalControllerDialog: Boolean = false, + val showElementEditor: Boolean = false, + val hasPhysicalController: Boolean = false, + + // Element Editor State + val elementToEdit: ControlElement? = null, + val elementPositionsSnapshot: Map> = emptyMap(), +) diff --git a/app/src/main/java/app/gamenative/ui/enums/ConnectionState.kt b/app/src/main/java/app/gamenative/ui/enums/ConnectionState.kt new file mode 100644 index 000000000..d3c6a7824 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/enums/ConnectionState.kt @@ -0,0 +1,9 @@ +package app.gamenative.ui.enums + +enum class ConnectionState { + CONNECTED, + CONNECTING, + DISCONNECTED, + LOGGED_OUT, + OFFLINE_MODE, +} diff --git a/app/src/main/java/app/gamenative/ui/enums/HomeDestination.kt b/app/src/main/java/app/gamenative/ui/enums/HomeDestination.kt index 9cbc5ed4b..a4258b62b 100644 --- a/app/src/main/java/app/gamenative/ui/enums/HomeDestination.kt +++ b/app/src/main/java/app/gamenative/ui/enums/HomeDestination.kt @@ -11,7 +11,7 @@ import app.gamenative.R /** * Destinations for Home Screen */ -enum class HomeDestination(@StringRes val title: Int, val icon: ImageVector) { +enum class HomeDestination(@get:StringRes val title: Int, val icon: ImageVector) { Library(R.string.destination_library, Icons.AutoMirrored.Filled.ViewList), Downloads(R.string.destination_downloads, Icons.Filled.Download), Friends(R.string.destination_friends, Icons.Filled.Groups), diff --git a/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt b/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt new file mode 100644 index 000000000..4e64db672 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt @@ -0,0 +1,60 @@ +package app.gamenative.ui.enums + +import androidx.annotation.StringRes +import app.gamenative.R + +enum class LibraryTab( + @get:StringRes val labelResId: Int, + val showCustom: Boolean, + val showSteam: Boolean, + val showGoG: Boolean, + val showEpic: Boolean, + val installedOnly: Boolean, +) { + ALL( + labelResId = R.string.tab_all, + showCustom = true, + showSteam = true, + showGoG = true, + showEpic = true, + installedOnly = false, + ), + STEAM( + labelResId = R.string.tab_steam, + showCustom = false, + showSteam = true, + showGoG = false, + showEpic = false, + installedOnly = false, + ), + INSTALLED( + labelResId = R.string.tab_installed, + showCustom = true, + showSteam = true, + showGoG = true, + showEpic = true, + installedOnly = true, + ), + LOCAL( + labelResId = R.string.tab_local, + showCustom = true, + showSteam = false, + showGoG = false, + showEpic = false, + installedOnly = false, + ); + + companion object { + fun LibraryTab.next(): LibraryTab { + val values = entries + val nextIndex = (ordinal + 1) % values.size + return values[nextIndex] + } + + fun LibraryTab.previous(): LibraryTab { + val values = entries + val prevIndex = if (ordinal == 0) values.size - 1 else ordinal - 1 + return values[prevIndex] + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/enums/SortOption.kt b/app/src/main/java/app/gamenative/ui/enums/SortOption.kt new file mode 100644 index 000000000..d7073a21f --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/enums/SortOption.kt @@ -0,0 +1,35 @@ +package app.gamenative.ui.enums + +import androidx.annotation.StringRes +import app.gamenative.R + +/** + * Sort options for the library list. Icon mapping is handled separately in the UI layer + */ +enum class SortOption( + @param:StringRes val displayTextRes: Int, +) { + INSTALLED_FIRST(displayTextRes = R.string.sort_installed_first), + NAME_ASC(displayTextRes = R.string.sort_name_asc), + NAME_DESC(displayTextRes = R.string.sort_name_desc), + RECENTLY_PLAYED(displayTextRes = R.string.sort_recently_played), + SIZE_SMALLEST(displayTextRes = R.string.sort_size_smallest), + SIZE_LARGEST(displayTextRes = R.string.sort_size_largest), + ; + + companion object { + fun fromOrdinal(ordinal: Int): SortOption { + return entries.getOrElse(ordinal) { INSTALLED_FIRST } + } + + fun next(current: SortOption): SortOption { + val nextIndex = (current.ordinal + 1) % entries.size + return entries[nextIndex] + } + + fun previous(current: SortOption): SortOption { + val prevIndex = if (current.ordinal == 0) entries.size - 1 else current.ordinal - 1 + return entries[prevIndex] + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/icons/InputIcons.kt b/app/src/main/java/app/gamenative/ui/icons/InputIcons.kt new file mode 100644 index 000000000..146a59689 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/icons/InputIcons.kt @@ -0,0 +1,760 @@ +package app.gamenative.ui.icons + +import androidx.annotation.DrawableRes +import app.gamenative.R + +/** + * Input prompt icons from Kenney's Input Prompts asset pack. + * License: CC0 (Public Domain) - https://kenney.nl/assets/input-prompts + * + * Usage: + * ``` + * Icon( + * painter = painterResource(InputIcons.Xbox.buttonA), + * contentDescription = "A button" + * ) + * ``` + * + * Or with Image: + * ``` + * Image( + * painter = painterResource(InputIcons.Xbox.buttonA), + * contentDescription = "A button", + * modifier = Modifier.size(24.dp) + * ) + * ``` + */ +object InputIcons { + + /** + * Xbox controller icons. + * Includes face buttons, bumpers, triggers, D-pad, sticks, menu buttons, and controller variants. + */ + object Xbox { + // ==================== Face Buttons (Monochrome - Tintable) ==================== + @DrawableRes + val buttonA = R.drawable.ic_input_xbox_button_a + + @DrawableRes + val buttonB = R.drawable.ic_input_xbox_button_b + + @DrawableRes + val buttonX = R.drawable.ic_input_xbox_button_x + + @DrawableRes + val buttonY = R.drawable.ic_input_xbox_button_y + + // ==================== Face Buttons (Colored - with Xbox colors) ==================== + @DrawableRes + val buttonColorA = R.drawable.ic_input_xbox_button_color_a + + @DrawableRes + val buttonColorB = R.drawable.ic_input_xbox_button_color_b + + @DrawableRes + val buttonColorX = R.drawable.ic_input_xbox_button_color_x + + @DrawableRes + val buttonColorY = R.drawable.ic_input_xbox_button_color_y + + // ==================== Bumpers (Shoulder Buttons) ==================== + @DrawableRes + val lb = R.drawable.ic_input_xbox_lb + + @DrawableRes + val rb = R.drawable.ic_input_xbox_rb + + // ==================== Triggers ==================== + @DrawableRes + val lt = R.drawable.ic_input_xbox_lt + + @DrawableRes + val rt = R.drawable.ic_input_xbox_rt + + // ==================== Menu Buttons ==================== + + /** ☰ Menu/Start button */ + @DrawableRes + val menu = R.drawable.ic_input_xbox_button_menu + val start = menu + + /** ⧉ View/Select button */ + @DrawableRes + val view = R.drawable.ic_input_xbox_button_view + val select = view + + @DrawableRes + val share = R.drawable.ic_input_xbox_button_share + + @DrawableRes + val guide = R.drawable.ic_input_xbox_guide + + /** Back button (older controllers) */ + @DrawableRes + val back = R.drawable.ic_input_xbox_button_back + + @DrawableRes + val backIcon = R.drawable.ic_input_xbox_button_back_icon + + /** Start button (text version) */ + @DrawableRes + val startText = R.drawable.ic_input_xbox_button_start + + @DrawableRes + val startIcon = R.drawable.ic_input_xbox_button_start_icon + + // ==================== D-Pad (Standard) ==================== + @DrawableRes + val dpad = R.drawable.ic_input_xbox_dpad + + @DrawableRes + val dpadUp = R.drawable.ic_input_xbox_dpad_up + + @DrawableRes + val dpadDown = R.drawable.ic_input_xbox_dpad_down + + @DrawableRes + val dpadLeft = R.drawable.ic_input_xbox_dpad_left + + @DrawableRes + val dpadRight = R.drawable.ic_input_xbox_dpad_right + + @DrawableRes + val dpadAll = R.drawable.ic_input_xbox_dpad_all + + @DrawableRes + val dpadNone = R.drawable.ic_input_xbox_dpad_none + + @DrawableRes + val dpadHorizontal = R.drawable.ic_input_xbox_dpad_horizontal + + @DrawableRes + val dpadVertical = R.drawable.ic_input_xbox_dpad_vertical + + // ==================== D-Pad (Round Style) ==================== + @DrawableRes + val dpadRound = R.drawable.ic_input_xbox_dpad_round + + @DrawableRes + val dpadRoundUp = R.drawable.ic_input_xbox_dpad_round_up + + @DrawableRes + val dpadRoundDown = R.drawable.ic_input_xbox_dpad_round_down + + @DrawableRes + val dpadRoundLeft = R.drawable.ic_input_xbox_dpad_round_left + + @DrawableRes + val dpadRoundRight = R.drawable.ic_input_xbox_dpad_round_right + + @DrawableRes + val dpadRoundAll = R.drawable.ic_input_xbox_dpad_round_all + + @DrawableRes + val dpadRoundHorizontal = R.drawable.ic_input_xbox_dpad_round_horizontal + + @DrawableRes + val dpadRoundVertical = R.drawable.ic_input_xbox_dpad_round_vertical + + // ==================== Left Stick ==================== + @DrawableRes + val stickL = R.drawable.ic_input_xbox_stick_l + + @DrawableRes + val stickLPress = R.drawable.ic_input_xbox_stick_l_press + + @DrawableRes + val stickLUp = R.drawable.ic_input_xbox_stick_l_up + + @DrawableRes + val stickLDown = R.drawable.ic_input_xbox_stick_l_down + + @DrawableRes + val stickLLeft = R.drawable.ic_input_xbox_stick_l_left + + @DrawableRes + val stickLRight = R.drawable.ic_input_xbox_stick_l_right + + @DrawableRes + val stickLHorizontal = R.drawable.ic_input_xbox_stick_l_horizontal + + @DrawableRes + val stickLVertical = R.drawable.ic_input_xbox_stick_l_vertical + + // ==================== Right Stick ==================== + @DrawableRes + val stickR = R.drawable.ic_input_xbox_stick_r + + @DrawableRes + val stickRPress = R.drawable.ic_input_xbox_stick_r_press + + @DrawableRes + val stickRUp = R.drawable.ic_input_xbox_stick_r_up + + @DrawableRes + val stickRDown = R.drawable.ic_input_xbox_stick_r_down + + @DrawableRes + val stickRLeft = R.drawable.ic_input_xbox_stick_r_left + + @DrawableRes + val stickRRight = R.drawable.ic_input_xbox_stick_r_right + + @DrawableRes + val stickRHorizontal = R.drawable.ic_input_xbox_stick_r_horizontal + + @DrawableRes + val stickRVertical = R.drawable.ic_input_xbox_stick_r_vertical + + // ==================== Stick Text Labels ==================== + @DrawableRes + val ls = R.drawable.ic_input_xbox_ls + + @DrawableRes + val rs = R.drawable.ic_input_xbox_rs + + // ==================== Stick Variants ==================== + @DrawableRes + val stickSideL = R.drawable.ic_input_xbox_stick_side_l + + @DrawableRes + val stickSideR = R.drawable.ic_input_xbox_stick_side_r + + @DrawableRes + val stickTopL = R.drawable.ic_input_xbox_stick_top_l + + @DrawableRes + val stickTopR = R.drawable.ic_input_xbox_stick_top_r + + // ==================== Elite Paddles ==================== + @DrawableRes + val elitePaddleTopLeft = R.drawable.ic_input_xbox_elite_paddle_top_left + + @DrawableRes + val elitePaddleTopRight = R.drawable.ic_input_xbox_elite_paddle_top_right + + @DrawableRes + val elitePaddleBottomLeft = R.drawable.ic_input_xbox_elite_paddle_bottom_left + + @DrawableRes + val elitePaddleBottomRight = R.drawable.ic_input_xbox_elite_paddle_bottom_right + + // ==================== Controllers ==================== + @DrawableRes + val controller = R.drawable.ic_input_xbox_xboxseries + + @DrawableRes + val controllerXboxSeries = R.drawable.ic_input_xbox_controller_xboxseries + + @DrawableRes + val controllerXboxOne = R.drawable.ic_input_xbox_controller_xboxone + + @DrawableRes + val controllerXbox360 = R.drawable.ic_input_xbox_controller_xbox360 + + @DrawableRes + val controllerXboxAdaptive = R.drawable.ic_input_xbox_controller_xbox_adaptive + } + + /** + * Keyboard icons. + * Includes all letter keys, number keys, function keys, arrow keys, modifiers, and special keys. + */ + object Keyboard { + // ==================== Letter Keys (A-Z) ==================== + @DrawableRes + val a = R.drawable.ic_input_kbd_a + + @DrawableRes + val b = R.drawable.ic_input_kbd_b + + @DrawableRes + val c = R.drawable.ic_input_kbd_c + + @DrawableRes + val d = R.drawable.ic_input_kbd_d + + @DrawableRes + val e = R.drawable.ic_input_kbd_e + + @DrawableRes + val f = R.drawable.ic_input_kbd_f + + @DrawableRes + val g = R.drawable.ic_input_kbd_g + + @DrawableRes + val h = R.drawable.ic_input_kbd_h + + @DrawableRes + val i = R.drawable.ic_input_kbd_i + + @DrawableRes + val j = R.drawable.ic_input_kbd_j + + @DrawableRes + val k = R.drawable.ic_input_kbd_k + + @DrawableRes + val l = R.drawable.ic_input_kbd_l + + @DrawableRes + val m = R.drawable.ic_input_kbd_m + + @DrawableRes + val n = R.drawable.ic_input_kbd_n + + @DrawableRes + val o = R.drawable.ic_input_kbd_o + + @DrawableRes + val p = R.drawable.ic_input_kbd_p + + @DrawableRes + val q = R.drawable.ic_input_kbd_q + + @DrawableRes + val r = R.drawable.ic_input_kbd_r + + @DrawableRes + val s = R.drawable.ic_input_kbd_s + + @DrawableRes + val t = R.drawable.ic_input_kbd_t + + @DrawableRes + val u = R.drawable.ic_input_kbd_u + + @DrawableRes + val v = R.drawable.ic_input_kbd_v + + @DrawableRes + val w = R.drawable.ic_input_kbd_w + + @DrawableRes + val x = R.drawable.ic_input_kbd_x + + @DrawableRes + val y = R.drawable.ic_input_kbd_y + + @DrawableRes + val z = R.drawable.ic_input_kbd_z + + // ==================== Number Keys (0-9) ==================== + @DrawableRes + val key0 = R.drawable.ic_input_kbd_0 + + @DrawableRes + val key1 = R.drawable.ic_input_kbd_1 + + @DrawableRes + val key2 = R.drawable.ic_input_kbd_2 + + @DrawableRes + val key3 = R.drawable.ic_input_kbd_3 + + @DrawableRes + val key4 = R.drawable.ic_input_kbd_4 + + @DrawableRes + val key5 = R.drawable.ic_input_kbd_5 + + @DrawableRes + val key6 = R.drawable.ic_input_kbd_6 + + @DrawableRes + val key7 = R.drawable.ic_input_kbd_7 + + @DrawableRes + val key8 = R.drawable.ic_input_kbd_8 + + @DrawableRes + val key9 = R.drawable.ic_input_kbd_9 + + // ==================== Function Keys (F1-F12) ==================== + @DrawableRes + val f1 = R.drawable.ic_input_kbd_f1 + + @DrawableRes + val f2 = R.drawable.ic_input_kbd_f2 + + @DrawableRes + val f3 = R.drawable.ic_input_kbd_f3 + + @DrawableRes + val f4 = R.drawable.ic_input_kbd_f4 + + @DrawableRes + val f5 = R.drawable.ic_input_kbd_f5 + + @DrawableRes + val f6 = R.drawable.ic_input_kbd_f6 + + @DrawableRes + val f7 = R.drawable.ic_input_kbd_f7 + + @DrawableRes + val f8 = R.drawable.ic_input_kbd_f8 + + @DrawableRes + val f9 = R.drawable.ic_input_kbd_f9 + + @DrawableRes + val f10 = R.drawable.ic_input_kbd_f10 + + @DrawableRes + val f11 = R.drawable.ic_input_kbd_f11 + + @DrawableRes + val f12 = R.drawable.ic_input_kbd_f12 + + // ==================== Arrow Keys ==================== + @DrawableRes + val arrowUp = R.drawable.ic_input_kbd_arrow_up + + @DrawableRes + val arrowDown = R.drawable.ic_input_kbd_arrow_down + + @DrawableRes + val arrowLeft = R.drawable.ic_input_kbd_arrow_left + + @DrawableRes + val arrowRight = R.drawable.ic_input_kbd_arrow_right + + // ==================== Arrow Key Cluster ==================== + @DrawableRes + val arrows = R.drawable.ic_input_kbd_arrows + + @DrawableRes + val arrowsAll = R.drawable.ic_input_kbd_arrows_all + + @DrawableRes + val arrowsNone = R.drawable.ic_input_kbd_arrows_none + + @DrawableRes + val arrowsUp = R.drawable.ic_input_kbd_arrows_up + + @DrawableRes + val arrowsDown = R.drawable.ic_input_kbd_arrows_down + + @DrawableRes + val arrowsLeft = R.drawable.ic_input_kbd_arrows_left + + @DrawableRes + val arrowsRight = R.drawable.ic_input_kbd_arrows_right + + @DrawableRes + val arrowsHorizontal = R.drawable.ic_input_kbd_arrows_horizontal + + @DrawableRes + val arrowsVertical = R.drawable.ic_input_kbd_arrows_vertical + + // ==================== Modifier Keys ==================== + @DrawableRes + val shift = R.drawable.ic_input_kbd_shift + + @DrawableRes + val shiftIcon = R.drawable.ic_input_kbd_shift_icon + + @DrawableRes + val ctrl = R.drawable.ic_input_kbd_ctrl + + @DrawableRes + val alt = R.drawable.ic_input_kbd_alt + + @DrawableRes + val command = R.drawable.ic_input_kbd_command + + @DrawableRes + val option = R.drawable.ic_input_kbd_option + + @DrawableRes + val win = R.drawable.ic_input_kbd_win + + @DrawableRes + val function = R.drawable.ic_input_kbd_function + + // ==================== Special Keys ==================== + @DrawableRes + val space = R.drawable.ic_input_kbd_space + + @DrawableRes + val spaceIcon = R.drawable.ic_input_kbd_space_icon + + @DrawableRes + val tab = R.drawable.ic_input_kbd_tab + + @DrawableRes + val tabIcon = R.drawable.ic_input_kbd_tab_icon + + @DrawableRes + val tabIconAlt = R.drawable.ic_input_kbd_tab_icon_alternative + + @DrawableRes + val enter = R.drawable.ic_input_kbd_enter + + @DrawableRes + val returnKey = R.drawable.ic_input_kbd_return + + @DrawableRes + val escape = R.drawable.ic_input_kbd_escape + + @DrawableRes + val backspace = R.drawable.ic_input_kbd_backspace + + @DrawableRes + val backspaceIcon = R.drawable.ic_input_kbd_backspace_icon + + @DrawableRes + val backspaceIconAlt = R.drawable.ic_input_kbd_backspace_icon_alternative + + @DrawableRes + val delete = R.drawable.ic_input_kbd_delete + + @DrawableRes + val insert = R.drawable.ic_input_kbd_insert + + @DrawableRes + val home = R.drawable.ic_input_kbd_home + + @DrawableRes + val end = R.drawable.ic_input_kbd_end + + @DrawableRes + val pageUp = R.drawable.ic_input_kbd_page_up + + @DrawableRes + val pageDown = R.drawable.ic_input_kbd_page_down + + @DrawableRes + val capslock = R.drawable.ic_input_kbd_capslock + + @DrawableRes + val capslockIcon = R.drawable.ic_input_kbd_capslock_icon + + @DrawableRes + val numlock = R.drawable.ic_input_kbd_numlock + + @DrawableRes + val printscreen = R.drawable.ic_input_kbd_printscreen + + // ==================== Numpad Keys ==================== + @DrawableRes + val numpadEnter = R.drawable.ic_input_kbd_numpad_enter + + @DrawableRes + val numpadPlus = R.drawable.ic_input_kbd_numpad_plus + + // ==================== Punctuation & Symbols ==================== + @DrawableRes + val apostrophe = R.drawable.ic_input_kbd_apostrophe + + @DrawableRes + val quote = R.drawable.ic_input_kbd_quote + + @DrawableRes + val bracketOpen = R.drawable.ic_input_kbd_bracket_open + + @DrawableRes + val bracketClose = R.drawable.ic_input_kbd_bracket_close + + @DrawableRes + val bracketLess = R.drawable.ic_input_kbd_bracket_less + + @DrawableRes + val bracketGreater = R.drawable.ic_input_kbd_bracket_greater + + @DrawableRes + val colon = R.drawable.ic_input_kbd_colon + + @DrawableRes + val semicolon = R.drawable.ic_input_kbd_semicolon + + @DrawableRes + val comma = R.drawable.ic_input_kbd_comma + + @DrawableRes + val period = R.drawable.ic_input_kbd_period + + @DrawableRes + val question = R.drawable.ic_input_kbd_question + + @DrawableRes + val exclamation = R.drawable.ic_input_kbd_exclamation + + @DrawableRes + val asterisk = R.drawable.ic_input_kbd_asterisk + + @DrawableRes + val caret = R.drawable.ic_input_kbd_caret + + @DrawableRes + val tilde = R.drawable.ic_input_kbd_tilde + + @DrawableRes + val minus = R.drawable.ic_input_kbd_minus + + @DrawableRes + val plus = R.drawable.ic_input_kbd_plus + + @DrawableRes + val equals = R.drawable.ic_input_kbd_equals + + @DrawableRes + val slashForward = R.drawable.ic_input_kbd_slash_forward + + @DrawableRes + val slashBack = R.drawable.ic_input_kbd_slash_back + + // ==================== Other ==================== + @DrawableRes + val keyboard = R.drawable.ic_input_kbd_keyboard + + @DrawableRes + val any = R.drawable.ic_input_kbd_any + + @DrawableRes + val outline = R.drawable.ic_input_kbd_outline + } + + /** + * Mouse icons. + * Includes mouse device, buttons, and scroll wheel. + */ + object Mouse { + // ==================== Device ==================== + @DrawableRes + val device = R.drawable.ic_input_kbd_mouse + + @DrawableRes + val deviceSmall = R.drawable.ic_input_kbd_mouse_small + + // ==================== Buttons ==================== + @DrawableRes + val left = R.drawable.ic_input_kbd_mouse_left + + @DrawableRes + val right = R.drawable.ic_input_kbd_mouse_right + + // ==================== Scroll Wheel ==================== + @DrawableRes + val scroll = R.drawable.ic_input_kbd_mouse_scroll + + @DrawableRes + val scrollUp = R.drawable.ic_input_kbd_mouse_scroll_up + + @DrawableRes + val scrollDown = R.drawable.ic_input_kbd_mouse_scroll_down + + @DrawableRes + val scrollVertical = R.drawable.ic_input_kbd_mouse_scroll_vertical + + // ==================== Movement ==================== + @DrawableRes + val move = R.drawable.ic_input_kbd_mouse_move + + @DrawableRes + val horizontal = R.drawable.ic_input_kbd_mouse_horizontal + + @DrawableRes + val vertical = R.drawable.ic_input_kbd_mouse_vertical + } + + /** + * Touch gesture icons. + * Includes taps, swipes, pinch-to-zoom, and rotation gestures. + */ + object Touch { + // ==================== Fingers ==================== + @DrawableRes + val fingerOne = R.drawable.ic_input_touch_finger_one + + @DrawableRes + val fingerTwo = R.drawable.ic_input_touch_finger_two + + // ==================== Hands ==================== + @DrawableRes + val handOpen = R.drawable.ic_input_touch_hand_open + + @DrawableRes + val handClosed = R.drawable.ic_input_touch_hand_closed + + // ==================== Single Tap ==================== + @DrawableRes + val tap = R.drawable.ic_input_touch_tap + + @DrawableRes + val tapDouble = R.drawable.ic_input_touch_tap_double + + @DrawableRes + val tapHold = R.drawable.ic_input_touch_tap_hold + + // ==================== Two Finger Tap ==================== + @DrawableRes + val twoFingers = R.drawable.ic_input_touch_two + + @DrawableRes + val twoFingersDouble = R.drawable.ic_input_touch_two_double + + @DrawableRes + val twoFingersHold = R.drawable.ic_input_touch_two_hold + + // ==================== Single Finger Swipe ==================== + @DrawableRes + val swipeUp = R.drawable.ic_input_touch_swipe_up + + @DrawableRes + val swipeDown = R.drawable.ic_input_touch_swipe_down + + @DrawableRes + val swipeLeft = R.drawable.ic_input_touch_swipe_left + + @DrawableRes + val swipeRight = R.drawable.ic_input_touch_swipe_right + + @DrawableRes + val swipeMove = R.drawable.ic_input_touch_swipe_move + + @DrawableRes + val swipeHorizontal = R.drawable.ic_input_touch_swipe_horizontal + + @DrawableRes + val swipeVertical = R.drawable.ic_input_touch_swipe_vertical + + // ==================== Two Finger Swipe ==================== + @DrawableRes + val swipeTwoUp = R.drawable.ic_input_touch_swipe_two_up + + @DrawableRes + val swipeTwoDown = R.drawable.ic_input_touch_swipe_two_down + + @DrawableRes + val swipeTwoLeft = R.drawable.ic_input_touch_swipe_two_left + + @DrawableRes + val swipeTwoRight = R.drawable.ic_input_touch_swipe_two_right + + @DrawableRes + val swipeTwoMove = R.drawable.ic_input_touch_swipe_two_move + + @DrawableRes + val swipeTwoHorizontal = R.drawable.ic_input_touch_swipe_two_horizontal + + @DrawableRes + val swipeTwoVertical = R.drawable.ic_input_touch_swipe_two_vertical + + // ==================== Rotation ==================== + @DrawableRes + val rotateLeft = R.drawable.ic_input_touch_rotate_left + + @DrawableRes + val rotateRight = R.drawable.ic_input_touch_rotate_right + + // ==================== Zoom (Pinch) ==================== + @DrawableRes + val zoomIn = R.drawable.ic_input_touch_zoom_in + + @DrawableRes + val zoomOut = R.drawable.ic_input_touch_zoom_out + } +} diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 9c66b6c70..fbec3b58e 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -1,44 +1,44 @@ package app.gamenative.ui.model +import android.content.Context import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.gamenative.PrefManager import app.gamenative.PluviaApp +import app.gamenative.PrefManager +import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.data.SteamApp -import app.gamenative.data.GameSource import app.gamenative.db.dao.SteamAppDao +import app.gamenative.events.AndroidEvent import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter -import app.gamenative.events.AndroidEvent +import app.gamenative.ui.enums.SortOption import app.gamenative.utils.CustomGameScanner import app.gamenative.utils.GameCompatibilityCache import app.gamenative.utils.GameCompatibilityService -import app.gamenative.data.GameCompatibilityStatus import com.winlator.core.GPUInformation import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import android.content.Context import java.io.File import java.util.EnumSet import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber -import kotlin.math.max -import kotlin.math.min @HiltViewModel class LibraryViewModel @Inject constructor( @@ -63,8 +63,8 @@ class LibraryViewModel @Inject constructor( } // How many items loaded on one page of results - private var paginationCurrentPage: Int = 0; - private var lastPageInCurrentFilter: Int = 0; + private var paginationCurrentPage: Int = 0 + private var lastPageInCurrentFilter: Int = 0 // Complete and unfiltered app list private var appList: List = emptyList() @@ -134,6 +134,7 @@ class LibraryViewModel @Inject constructor( PrefManager.showSteamInLibrary = newValue _state.update { it.copy(showSteamInLibrary = newValue) } } + GameSource.CUSTOM_GAME -> { val newValue = !current.showCustomGamesInLibrary PrefManager.showCustomGamesInLibrary = newValue @@ -143,6 +144,21 @@ class LibraryViewModel @Inject constructor( onFilterApps(paginationCurrentPage) } + fun onSortOptionChanged(sortOption: SortOption) { + PrefManager.librarySortOption = sortOption + _state.update { it.copy(currentSortOption = sortOption) } + onFilterApps() + } + + fun onOptionsPanelToggle(isOpen: Boolean) { + _state.update { it.copy(isOptionsPanelOpen = isOpen) } + } + + fun onTabChanged(tab: app.gamenative.ui.enums.LibraryTab) { + _state.update { it.copy(currentTab = tab) } + onFilterApps(0) // Reset to first page and refresh + } + fun onSearchQuery(value: String) { _state.update { it.copy(searchQuery = value) } onFilterApps() @@ -236,7 +252,7 @@ class LibraryViewModel @Inject constructor( } ?: emptyList() }.let { owners -> if (owners.isEmpty()) { - true // no owner info ⇒ don’t filter the item out + true // no owner info ⇒ don’t filter the item out } else { owners.any { item.ownerAccountId.contains(it) } } @@ -260,7 +276,9 @@ class LibraryViewModel @Inject constructor( } } .filter { item -> - if (currentState.appInfoSortType.contains(AppFilter.INSTALLED)) { + val installedOnly = currentState.currentTab.installedOnly || + currentState.appInfoSortType.contains(AppFilter.INSTALLED) + if (installedOnly) { downloadDirectoryApps.contains(SteamService.getAppDirName(item)) } else { true @@ -269,7 +287,7 @@ class LibraryViewModel @Inject constructor( .sortedWith( compareByDescending { downloadDirectorySet.contains(SteamService.getAppDirName(it)) - }.thenBy { it.name.lowercase() } + }.thenBy { it.name.lowercase() }, ) .toList() @@ -277,6 +295,10 @@ class LibraryViewModel @Inject constructor( data class LibraryEntry(val item: LibraryItem, val isInstalled: Boolean) val steamEntries: List = filteredSteamApps.map { item -> val isInstalled = downloadDirectorySet.contains(SteamService.getAppDirName(item)) + // Calculate total size from all depot manifests (use "public" branch as default) + val totalSizeBytes = item.depots.values.sumOf { depot -> + depot.manifests["public"]?.size ?: depot.manifests.values.firstOrNull()?.size ?: 0L + } LibraryEntry( item = LibraryItem( index = 0, // temporary, will be re-indexed after combining and paginating @@ -284,6 +306,7 @@ class LibraryViewModel @Inject constructor( name = item.name, iconHash = item.clientIconHash, isShared = (PrefManager.steamUserAccountId != 0 && !item.ownerAccountId.contains(PrefManager.steamUserAccountId)), + sizeBytes = totalSizeBytes, ), isInstalled = isInstalled, ) @@ -293,7 +316,7 @@ class LibraryViewModel @Inject constructor( // Only include custom games if GAME filter is selected val customGameItems = if (currentState.appInfoSortType.contains(AppFilter.GAME)) { CustomGameScanner.scanAsLibraryItems( - query = currentState.searchQuery + query = currentState.searchQuery, ) } else { emptyList() @@ -308,20 +331,47 @@ class LibraryViewModel @Inject constructor( Timber.tag("LibraryViewModel").d("Saved counts - Custom: ${customGameItems.size}, Steam: ${filteredSteamApps.size}") } - // Apply App Source filters - val includeSteam = _state.value.showSteamInLibrary - val includeOpen = _state.value.showCustomGamesInLibrary + // Compute effective source filters based on current tab + // ALL tab uses user preferences, other tabs override with their presets + val currentTab = _state.value.currentTab + val includeSteam = if (currentTab == app.gamenative.ui.enums.LibraryTab.ALL) { + _state.value.showSteamInLibrary + } else { + currentTab.showSteam + } + val includeOpen = if (currentTab == app.gamenative.ui.enums.LibraryTab.ALL) { + _state.value.showCustomGamesInLibrary + } else { + currentTab.showCustom + } + + // Combine both lists and apply sort option + val sortComparator: Comparator = when (currentState.currentSortOption) { + SortOption.INSTALLED_FIRST -> compareBy { entry -> + if (entry.isInstalled) 0 else 1 + }.thenBy { it.item.name.lowercase() } + + SortOption.NAME_ASC -> compareBy { it.item.name.lowercase() } + + SortOption.NAME_DESC -> compareByDescending { it.item.name.lowercase() } + + SortOption.RECENTLY_PLAYED -> compareBy { entry -> + if (entry.isInstalled) 0 else 1 + }.thenBy { it.item.name.lowercase() } - // Combine both lists - val combined = buildList { + SortOption.SIZE_SMALLEST -> compareBy { it.item.sizeBytes } + .thenBy { it.item.name.lowercase() } + + SortOption.SIZE_LARGEST -> compareByDescending { it.item.sizeBytes } + .thenBy { it.item.name.lowercase() } + } + + val combined = buildList { if (includeSteam) addAll(steamEntries) if (includeOpen) addAll(customEntries) - }.sortedWith( - // Always sort by installed status first (installed games at top), then alphabetically within each group - compareBy { entry -> - if (entry.isInstalled) 0 else 1 - }.thenBy { it.item.name.lowercase() } // Alphabetical sorting within installed and uninstalled groups - ).mapIndexed { idx, entry -> entry.item.copy(index = idx) } + }.sortedWith(sortComparator).mapIndexed { idx, entry -> + entry.item.copy(index = idx, isInstalled = entry.isInstalled) + } // Total count for the current filter val totalFound = combined.size @@ -335,7 +385,7 @@ class LibraryViewModel @Inject constructor( val endIndex = min((paginationPage + 1) * pageSize, totalFound) val pagedList = combined.take(endIndex) - Timber.tag("LibraryViewModel").d("Filtered list size (with Custom Games): ${totalFound}") + Timber.tag("LibraryViewModel").d("Filtered list size (with Custom Games): $totalFound") if (isFirstLoad) { isFirstLoad = false diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index b02a00dbc..17bda4619 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -2,12 +2,12 @@ package app.gamenative.ui.model import android.content.Context import android.os.Process +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.data.GameProcessInfo -import app.gamenative.data.LibraryItem import app.gamenative.di.IAppTheme import app.gamenative.enums.AppTheme import app.gamenative.enums.LoginResult @@ -16,18 +16,22 @@ import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService import app.gamenative.ui.data.MainState -import app.gamenative.utils.IntentLaunchManager +import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.screen.PluviaScreen +import app.gamenative.utils.ContainerUtils +import app.gamenative.utils.IntentLaunchManager import app.gamenative.utils.SteamUtils import app.gamenative.utils.UpdateInfo import com.materialkolor.PaletteStyle import com.winlator.xserver.Window import dagger.hilt.android.lifecycle.HiltViewModel import `in`.dragonbra.javasteam.steam.handlers.steamapps.AppProcessInfo -import kotlinx.coroutines.Dispatchers import java.nio.file.Paths import javax.inject.Inject import kotlin.io.path.name +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -37,15 +41,17 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import kotlinx.coroutines.Job -import app.gamenative.utils.ContainerUtils -import kotlinx.coroutines.async @HiltViewModel class MainViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, private val appTheme: IAppTheme, ) : ViewModel() { + companion object { + private const val KEY_CURRENT_SCREEN_ROUTE = "current_screen_route" + } + sealed class MainUiEvent { data object OnBackPressed : MainUiEvent() data object OnLoggedOut : MainUiEvent() @@ -66,7 +72,9 @@ class MainViewModel @Inject constructor( private val _offline = MutableStateFlow(false) val isOffline: StateFlow get() = _offline - fun setOffline(value: Boolean) { _offline.value = value } + fun setOffline(value: Boolean) { + _offline.value = value + } private val _updateInfo = MutableStateFlow(null) val updateInfo: StateFlow = _updateInfo.asStateFlow() @@ -77,16 +85,52 @@ class MainViewModel @Inject constructor( private val onSteamConnected: (SteamEvent.Connected) -> Unit = { Timber.i("Received is connected") - _state.update { it.copy(isSteamConnected = true) } + _state.update { + it.copy( + isSteamConnected = true, + connectionState = if (it.connectionState == ConnectionState.CONNECTING) { + ConnectionState.CONNECTING // Still connecting until login completes + } else { + it.connectionState + }, + ) + } } private val onSteamDisconnected: (SteamEvent.Disconnected) -> Unit = { Timber.i("Received disconnected from Steam") - _state.update { it.copy(isSteamConnected = false) } + _state.update { + it.copy( + isSteamConnected = false, + connectionState = if (it.connectionState != ConnectionState.OFFLINE_MODE) { + ConnectionState.DISCONNECTED + } else { + it.connectionState // Keep offline mode if user chose it + }, + connectionMessage = null, + ) + } + } + + private val onRemotelyDisconnected: (SteamEvent.RemotelyDisconnected) -> Unit = { + Timber.i("Received remotely disconnected from Steam") + _state.update { + it.copy( + isSteamConnected = false, + connectionState = ConnectionState.DISCONNECTED, + connectionMessage = null, + ) + } } private val onLoggingIn: (SteamEvent.LogonStarted) -> Unit = { Timber.i("Received logon started") + _state.update { + it.copy( + connectionState = ConnectionState.CONNECTING, + connectionMessage = null, + ) + } } private val onBackPressed: (AndroidEvent.BackPressed) -> Unit = { @@ -95,10 +139,35 @@ class MainViewModel @Inject constructor( } } - private val onLogonEnded: (SteamEvent.LogonEnded) -> Unit = { - Timber.i("Received logon ended") + private val onLogonEnded: (SteamEvent.LogonEnded) -> Unit = { event -> + Timber.i("Received logon ended with result: ${event.loginResult}") viewModelScope.launch { - _uiEvent.send(MainUiEvent.OnLogonEnded(it.loginResult)) + _uiEvent.send(MainUiEvent.OnLogonEnded(event.loginResult)) + } + // Update connection state based on login result + when (event.loginResult) { + LoginResult.Success -> { + _state.update { + it.copy( + connectionState = ConnectionState.CONNECTED, + connectionMessage = null, + connectionTimeoutSeconds = 0, + ) + } + } + + LoginResult.Failed -> { + _state.update { + it.copy( + connectionState = ConnectionState.DISCONNECTED, + connectionMessage = event.message, // null falls back to UI string resource + ) + } + } + + else -> { + // DeviceAuth, DeviceConfirm, EmailAuth, InProgress - keep connecting state + } } } @@ -107,6 +176,14 @@ class MainViewModel @Inject constructor( viewModelScope.launch { _uiEvent.send(MainUiEvent.OnLoggedOut) } + // Session expired or user logged out - must re-authenticate + _state.update { + it.copy( + connectionState = ConnectionState.LOGGED_OUT, + connectionMessage = null, + isSteamConnected = false, + ) + } } private val onExternalGameLaunch: (AndroidEvent.ExternalGameLaunch) -> Unit = { @@ -122,17 +199,49 @@ class MainViewModel @Inject constructor( } private var bootingSplashTimeoutJob: Job? = null + private var connectionTimeoutJob: Job? = null init { + // Restore persisted screen from SavedStateHandle if available + val persistedRoute = savedStateHandle.get(KEY_CURRENT_SCREEN_ROUTE) + val restoredScreen = when (persistedRoute) { + PluviaScreen.Home.route -> PluviaScreen.Home + PluviaScreen.XServer.route -> PluviaScreen.XServer + PluviaScreen.Settings.route -> PluviaScreen.Settings + PluviaScreen.Chat.route -> PluviaScreen.Chat + else -> PluviaScreen.LoginUser + } + + // Determine initial connection state based on service state + // On app startup, Steam service is starting to connect, so default to CONNECTING + // Only use DISCONNECTED after an actual disconnect event occurs + val initialConnectionState = when { + SteamService.isLoggedIn -> ConnectionState.CONNECTED + else -> ConnectionState.CONNECTING // Service is starting up or connecting + } + + _state.update { + it.copy( + isSteamConnected = SteamService.isConnected, + hasCrashedLastStart = PrefManager.recentlyCrashed, + launchedAppId = "", + currentScreen = restoredScreen, + connectionState = initialConnectionState, + ) + } + + // Register event handlers PluviaApp.events.on(onBackPressed) PluviaApp.events.on(onExternalGameLaunch) PluviaApp.events.on(onSetBootingSplashText) PluviaApp.events.on(onSteamConnected) PluviaApp.events.on(onSteamDisconnected) + PluviaApp.events.on(onRemotelyDisconnected) PluviaApp.events.on(onLoggingIn) PluviaApp.events.on(onLogonEnded) PluviaApp.events.on(onLoggedOut) + // Collect theme preferences viewModelScope.launch { appTheme.themeFlow.collect { value -> _state.update { it.copy(appTheme = value) } @@ -152,18 +261,11 @@ class MainViewModel @Inject constructor( PluviaApp.events.off(onSetBootingSplashText) PluviaApp.events.off(onSteamConnected) PluviaApp.events.off(onSteamDisconnected) + PluviaApp.events.off(onRemotelyDisconnected) + PluviaApp.events.off(onLoggingIn) PluviaApp.events.off(onLogonEnded) PluviaApp.events.off(onLoggedOut) - } - - init { - _state.update { - it.copy( - isSteamConnected = SteamService.isConnected, - hasCrashedLastStart = PrefManager.recentlyCrashed, - launchedAppId = "", - ) - } + connectionTimeoutJob?.cancel() } fun setTheme(value: AppTheme) { @@ -202,13 +304,70 @@ class MainViewModel @Inject constructor( _state.update { it.copy(bootingSplashText = value) } } + // Connection state management + + /** + * Called when starting a reconnection attempt. + * Sets state to CONNECTING and starts a timeout counter. + */ + fun startConnecting(message: String? = null) { + connectionTimeoutJob?.cancel() + _state.update { + it.copy( + connectionState = ConnectionState.CONNECTING, + connectionMessage = message, + connectionTimeoutSeconds = 0, + ) + } + + // Start timeout counter + connectionTimeoutJob = viewModelScope.launch { + var seconds = 0 + while (seconds < 30 && _state.value.connectionState == ConnectionState.CONNECTING) { + delay(1000) + seconds++ + _state.update { it.copy(connectionTimeoutSeconds = seconds) } + } + } + } + + /** + * Called when user chooses to continue in offline mode. + * Stops reconnection attempts and allows app to function offline. + */ + fun continueOffline() { + connectionTimeoutJob?.cancel() + _state.update { + it.copy( + connectionState = ConnectionState.OFFLINE_MODE, + connectionMessage = null, + connectionTimeoutSeconds = 0, + ) + } + } + + /** + * Called when user wants to retry connection. + * Resets offline mode and triggers reconnection. + */ + fun retryConnection() { + if (_state.value.connectionState == ConnectionState.OFFLINE_MODE || + _state.value.connectionState == ConnectionState.DISCONNECTED + ) { + startConnecting() + } + } + fun setCurrentScreen(currentScreen: String?) { - val screen = when (currentScreen) { - PluviaScreen.LoginUser.route -> PluviaScreen.LoginUser - PluviaScreen.Home.route -> PluviaScreen.Home - PluviaScreen.XServer.route -> PluviaScreen.XServer - PluviaScreen.Settings.route -> PluviaScreen.Settings - PluviaScreen.Chat.route -> PluviaScreen.Chat + // Route matching accounts for query params and path params in templates + // e.g., "home?offline={offline}" should match Home, "chat/{id}" should match Chat + val screen = when { + currentScreen == null -> PluviaScreen.LoginUser + currentScreen == PluviaScreen.LoginUser.route -> PluviaScreen.LoginUser + currentScreen.startsWith(PluviaScreen.Home.route) -> PluviaScreen.Home + currentScreen == PluviaScreen.XServer.route -> PluviaScreen.XServer + currentScreen == PluviaScreen.Settings.route -> PluviaScreen.Settings + currentScreen.startsWith("chat") -> PluviaScreen.Chat else -> PluviaScreen.LoginUser } @@ -217,6 +376,36 @@ class MainViewModel @Inject constructor( fun setCurrentScreen(value: PluviaScreen) { _state.update { it.copy(currentScreen = value) } + savedStateHandle[KEY_CURRENT_SCREEN_ROUTE] = value.route + } + + /** + * Gets the persisted route from SavedStateHandle + * + * Returns the route the user was on before process death, or null if: + * - No route was persisted + * - The persisted route is LoginUser (not meaningful to restore) + * - The persisted route is XServer (game session is gone after process death) + * - The persisted route is Chat (dynamic IDs require special handling) + * + * Navigation decisions should be made by the caller based on the current + * NavController destination, not by tracking internal flags. + * + * TODO: reconsider this approach when merging GOG and Epic + */ + fun getPersistedRoute(): String? { + val persistedRoute = savedStateHandle.get(KEY_CURRENT_SCREEN_ROUTE) + return when { + persistedRoute == null -> null + persistedRoute == PluviaScreen.LoginUser.route -> null + persistedRoute == PluviaScreen.XServer.route -> null + persistedRoute.startsWith("chat") -> null + else -> persistedRoute + } + } + + fun clearPersistedRoute() { + savedStateHandle[KEY_CURRENT_SCREEN_ROUTE] = PluviaScreen.LoginUser.route } fun setHasCrashedLastStart(value: Boolean) { @@ -288,26 +477,26 @@ class MainViewModel @Inject constructor( } // After app closes, check if we need to show the feedback dialog + // Show feedback if: first time running this game OR config was changed try { val container = ContainerUtils.getContainer(context, appId) - val shown = container.getExtra("discord_support_prompt_shown", "false") == "true" + val alreadyShown = container.getExtra("discord_support_prompt_shown", "false") == "true" val configChanged = container.getExtra("config_changed", "false") == "true" - if (!shown) { - container.putExtra("discord_support_prompt_shown", "true") - container.saveData() - _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) - } - // Only show feedback if container config was changed before this game run - if (configChanged) { - // Clear the flag - container.putExtra("config_changed", "false") + // Determine if we should show the feedback dialog (only once per exit) + val shouldShowFeedback = !alreadyShown || configChanged + + if (shouldShowFeedback) { + // Mark as shown and clear config changed flag + container.putExtra("discord_support_prompt_shown", "true") + if (configChanged) { + container.putExtra("config_changed", "false") + } container.saveData() - // Show the feedback dialog _uiEvent.send(MainUiEvent.ShowGameFeedbackDialog(appId)) } - } catch (_: Exception) { - // ignore container errors + } catch (e: Exception) { + Timber.w(e, "Failed to check/update feedback dialog state for $appId") } } } diff --git a/app/src/main/java/app/gamenative/ui/model/UserLoginViewModel.kt b/app/src/main/java/app/gamenative/ui/model/UserLoginViewModel.kt index 8374d1618..2087d39b8 100644 --- a/app/src/main/java/app/gamenative/ui/model/UserLoginViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/UserLoginViewModel.kt @@ -1,6 +1,5 @@ package app.gamenative.ui.model -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.gamenative.PluviaApp @@ -10,7 +9,7 @@ import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService import app.gamenative.ui.data.UserLoginState -import app.gamenative.ui.screen.PluviaScreen +import com.posthog.PostHog import `in`.dragonbra.javasteam.steam.authentication.IAuthenticator import java.util.concurrent.CompletableFuture import kotlinx.coroutines.channels.Channel @@ -21,8 +20,16 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import com.posthog.PostHog +/** + * ViewModel for the login screen. + * + * Note: Connection state is managed by MainViewModel and passed to UserLoginScreen + * as a parameter. This ViewModel only handles login-specific state (username, password, + * 2FA, etc.) to maintain a single source of truth for connection status. + * + * Worth reconsidering once we merge GoG and Epic integration. + */ class UserLoginViewModel : ViewModel() { private val _loginState = MutableStateFlow(UserLoginState()) val loginState: StateFlow = _loginState.asStateFlow() @@ -41,7 +48,7 @@ class UserLoginViewModel : ViewModel() { loginResult = LoginResult.DeviceConfirm, loginScreen = LoginScreen.TWO_FACTOR, isLoggingIn = false, - lastTwoFactorMethod = "steam_guard" + lastTwoFactorMethod = "steam_guard", ) } @@ -57,7 +64,7 @@ class UserLoginViewModel : ViewModel() { loginScreen = LoginScreen.TWO_FACTOR, isLoggingIn = false, previousCodeIncorrect = previousCodeWasIncorrect, - lastTwoFactorMethod = "authenticator_code" + lastTwoFactorMethod = "authenticator_code", ) } @@ -82,7 +89,7 @@ class UserLoginViewModel : ViewModel() { isLoggingIn = false, email = email, previousCodeIncorrect = previousCodeWasIncorrect, - lastTwoFactorMethod = "email_code" + lastTwoFactorMethod = "email_code", ) } @@ -97,27 +104,14 @@ class UserLoginViewModel : ViewModel() { private val onSteamConnected: (SteamEvent.Connected) -> Unit = { Timber.i("Received is connected") - - _loginState.update { currentState -> - currentState.copy( - isLoggingIn = it.isAutoLoggingIn, - isSteamConnected = true, - ) - } - } - - private val onSteamDisconnected: (SteamEvent.Disconnected) -> Unit = { - Timber.i("Received disconnected from Steam") - _loginState.update { currentState -> - currentState.copy(isSteamConnected = false) + // Only handle auto-login state, connection state is managed by MainViewModel + if (it.isAutoLoggingIn) { + _loginState.update { currentState -> + currentState.copy(isLoggingIn = true) + } } } - private val onRemoteDisconnected: (SteamEvent.RemotelyDisconnected) -> Unit = { - Timber.i("Disconnected from steam remotely") - _loginState.update { it.copy(isSteamConnected = false) } - } - private val onLogonStarted: (SteamEvent.LogonStarted) -> Unit = { _loginState.update { currentState -> currentState.copy(isLoggingIn = true) @@ -137,38 +131,44 @@ class UserLoginViewModel : ViewModel() { // PostHog logging val method = when (prevState.loginScreen) { LoginScreen.QR -> "qr" - else -> "credentials" + LoginScreen.TWO_FACTOR -> prevState.lastTwoFactorMethod ?: "unknown_2fa" + LoginScreen.CREDENTIAL -> "password" } - val twoFactorMethod = prevState.lastTwoFactorMethod - val eventProps = mutableMapOf("method" to method) - twoFactorMethod?.let { eventProps["2fa_method"] = it } - if (it.loginResult == LoginResult.Success) { - PostHog.capture(event = "login_success", properties = eventProps) + PostHog.capture( + event = "login_success", + properties = mapOf("method" to method), + ) } else if (it.loginResult == LoginResult.Failed) { - PostHog.capture(event = "login_failed", properties = eventProps) + PostHog.capture( + event = "login_failed", + properties = mapOf( + "method" to method, + "reason" to (it.message ?: "unknown"), + ), + ) + it.message?.let(::showSnack) } - - it.message?.let(::showSnack) - - // Why is this here? - Lossy Jan 17 2025 - // if (it.loginResult != LoginResult.Success) { - // SteamService.startLoginWithQr() - // } } private val onBackPressed: (AndroidEvent.BackPressed) -> Unit = { - if (!_loginState.value.isLoggingIn) { + val currentLoginScreen = _loginState.value.loginScreen + if (currentLoginScreen == LoginScreen.TWO_FACTOR) { _loginState.update { currentState -> - currentState.copy(loginResult = LoginResult.Failed) + currentState.copy(loginScreen = LoginScreen.CREDENTIAL) + } + } else if (currentLoginScreen == LoginScreen.QR) { + _loginState.update { currentState -> + currentState.copy(loginScreen = LoginScreen.CREDENTIAL) } } + // From credential screen, back press is handled by the system (exits app) } - private val onQrChallengeReceived: (SteamEvent.QrChallengeReceived) -> Unit = { + private val onQrChallengeReceived: (SteamEvent.QrChallengeReceived) -> Unit = { event -> _loginState.update { currentState -> - currentState.copy(isQrFailed = false, qrCode = it.challengeUrl) + currentState.copy(qrCode = event.challengeUrl, isQrFailed = false) } } @@ -184,7 +184,6 @@ class UserLoginViewModel : ViewModel() { Timber.i("Received logged out") _loginState.update { it.copy( - isSteamConnected = false, isLoggingIn = false, isQrFailed = false, loginResult = LoginResult.Failed, @@ -197,25 +196,19 @@ class UserLoginViewModel : ViewModel() { Timber.d("init") PluviaApp.events.on(onSteamConnected) - PluviaApp.events.on(onSteamDisconnected) PluviaApp.events.on(onLogonStarted) PluviaApp.events.on(onLogonEnded) PluviaApp.events.on(onBackPressed) PluviaApp.events.on(onQrChallengeReceived) PluviaApp.events.on(onQrAuthEnded) PluviaApp.events.on(onLoggedOut) - PluviaApp.events.on(onRemoteDisconnected) val isLoggedIn = SteamService.isLoggedIn - val isSteamConnected = SteamService.isConnected Timber.d("Logged in? $isLoggedIn") + if (isLoggedIn) { _loginState.update { - it.copy(isSteamConnected = isSteamConnected, isLoggingIn = true, isQrFailed = false, loginResult = LoginResult.Success) - } - } else { - _loginState.update { - it.copy(isSteamConnected = isSteamConnected, isLoggingIn = false, isQrFailed = false, loginResult = LoginResult.Failed) + it.copy(isLoggingIn = true, isQrFailed = false, loginResult = LoginResult.Success) } } } @@ -224,7 +217,6 @@ class UserLoginViewModel : ViewModel() { Timber.d("onCleared") PluviaApp.events.off(onSteamConnected) - PluviaApp.events.off(onSteamDisconnected) PluviaApp.events.off(onLogonStarted) PluviaApp.events.off(onLogonEnded) PluviaApp.events.off(onBackPressed) @@ -248,7 +240,7 @@ class UserLoginViewModel : ViewModel() { } viewModelScope.launch { - app.gamenative.service.SteamService.startLoginWithCredentials( + SteamService.startLoginWithCredentials( username = username, password = password, rememberSession = rememberSession, @@ -268,19 +260,30 @@ class UserLoginViewModel : ViewModel() { } } - fun onQrRetry() { - viewModelScope.launch { SteamService.startLoginWithQr() } - } - fun onShowLoginScreen(loginScreen: LoginScreen) { - when (loginScreen) { - LoginScreen.CREDENTIAL -> SteamService.stopLoginWithQr() - LoginScreen.QR -> viewModelScope.launch { SteamService.startLoginWithQr() } - else -> Timber.w("onShowLoginScreen ended up in an unknown state: ${loginScreen.name}") + _loginState.update { currentState -> + currentState.copy( + loginScreen = loginScreen, + isQrFailed = false, + qrCode = null, + ) + } + + if (loginScreen == LoginScreen.QR) { + viewModelScope.launch { + SteamService.startLoginWithQr() + } + } else { + SteamService.stopLoginWithQr() } + } + fun onQrRetry() { _loginState.update { currentState -> - currentState.copy(loginScreen = loginScreen) + currentState.copy(isQrFailed = false, qrCode = null) + } + viewModelScope.launch { + SteamService.startLoginWithQr() } } @@ -307,27 +310,4 @@ class UserLoginViewModel : ViewModel() { currentState.copy(twoFactorCode = twoFactorCode) } } - - fun retryConnection(context: Context) { - // Reset error/login state if needed - _loginState.update { currentState -> - currentState.copy( - isLoggingIn = false, - loginResult = LoginResult.Failed, - isSteamConnected = false, - isQrFailed = false, - qrCode = null - ) - } - // Restart the SteamService - viewModelScope.launch { - try { - val intent = android.content.Intent(context, app.gamenative.service.SteamService::class.java) - context.startForegroundService(intent) - } catch (e: Exception) { - Timber.e(e, "Failed to restart SteamService in retryConnection") - showSnack("Failed to restart Steam connection: ${e.localizedMessage}") - } - } - } } diff --git a/app/src/main/java/app/gamenative/ui/model/XServerViewModel.kt b/app/src/main/java/app/gamenative/ui/model/XServerViewModel.kt index b725e14d4..315c95ebc 100644 --- a/app/src/main/java/app/gamenative/ui/model/XServerViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/XServerViewModel.kt @@ -4,62 +4,130 @@ import androidx.lifecycle.ViewModel import app.gamenative.ui.data.XServerState import com.winlator.core.KeyValueSet import com.winlator.core.WineInfo +import com.winlator.inputcontrols.ControlElement +import com.winlator.inputcontrols.ControllerManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import timber.log.Timber -class XServerViewModel : ViewModel() { - private val _xServerState = MutableStateFlow(XServerState()) - val xServerState: StateFlow = _xServerState.asStateFlow() +@HiltViewModel +class XServerViewModel @Inject constructor() : ViewModel() { - // fun setEnvVars(envVars: EnvVars) { - // _xServerState.update { currentState -> - // currentState.copy(envVars = envVars) - // } - // } + private val _state = MutableStateFlow(XServerState()) + val state: StateFlow = _state.asStateFlow() + + init { + // Check for physical controller on init + val controllerManager = ControllerManager.getInstance() + controllerManager.scanForDevices() + val hasController = controllerManager.detectedDevices.isNotEmpty() + _state.update { it.copy(hasPhysicalController = hasController) } + } + + // Wine/Container Configuration Setters fun setDxwrapper(dxwrapper: String) { - _xServerState.update { currentState -> - currentState.copy(dxwrapper = dxwrapper) - } + _state.update { it.copy(dxwrapper = dxwrapper) } } fun setDxwrapperConfig(dxwrapperConfig: KeyValueSet?) { - _xServerState.update { currentState -> - Timber.i("Setting dxwrapperConfig to $dxwrapperConfig") - currentState.copy(dxwrapperConfig = dxwrapperConfig) - } + Timber.d("Setting dxwrapperConfig to $dxwrapperConfig") + _state.update { it.copy(dxwrapperConfig = dxwrapperConfig) } + } + + fun setWineInfo(wineInfo: WineInfo) { + _state.update { it.copy(wineInfo = wineInfo) } + } + + fun setWinStarted(started: Boolean) { + _state.update { it.copy(winStarted = started) } } - // fun setShortcut(shortcut: Shortcut?) { - // _xServerState.update { currentState -> - // currentState.copy(shortcut = shortcut) - // } - // } + // UI State Setters - fun setScreenSize(screenSize: String) { - _xServerState.update { currentState -> - currentState.copy(screenSize = screenSize) - } + fun setControlsVisible(visible: Boolean) { + _state.update { it.copy(areControlsVisible = visible) } } - fun setWineInfo(wineInfo: WineInfo) { - _xServerState.update { currentState -> - currentState.copy(wineInfo = wineInfo) + fun toggleControlsVisible() { + _state.update { it.copy(areControlsVisible = !it.areControlsVisible) } + } + + fun setEditMode(enabled: Boolean) { + _state.update { it.copy(isEditMode = enabled) } + } + + fun setShowQuickMenu(show: Boolean) { + _state.update { it.copy(showQuickMenu = show) } + } + + fun setShowPhysicalControllerDialog(show: Boolean) { + _state.update { it.copy(showPhysicalControllerDialog = show) } + } + + fun setShowElementEditor(show: Boolean) { + _state.update { it.copy(showElementEditor = show) } + } + + fun setElementToEdit(element: ControlElement?) { + _state.update { it.copy(elementToEdit = element) } + } + + // Edit Mode Operations + + fun enterEditMode(currentElements: List) { + val snapshot = mutableMapOf>() + currentElements.forEach { element -> + snapshot[element] = Pair(element.getX().toInt(), element.getY().toInt()) + } + _state.update { + it.copy( + isEditMode = true, + elementPositionsSnapshot = snapshot, + ) } } - fun setGraphicsDriver(graphicsDriver: String) { - _xServerState.update { currentState -> - currentState.copy(graphicsDriver = graphicsDriver) + fun exitEditMode(saveChanges: Boolean) { + if (!saveChanges) { + // Restore positions from snapshot + val snapshot = _state.value.elementPositionsSnapshot + snapshot.forEach { (element, position) -> + element.setX(position.first) + element.setY(position.second) + } + } + _state.update { + it.copy( + isEditMode = false, + elementPositionsSnapshot = emptyMap(), + ) } } - fun setAudioDriver(audioDriver: String) { - _xServerState.update { currentState -> - currentState.copy(audioDriver = audioDriver) + // State Initialization + + fun initializeFromContainer( + graphicsDriver: String, + graphicsDriverVersion: String, + audioDriver: String, + dxwrapper: String, + dxwrapperConfig: KeyValueSet?, + screenSize: String, + ) { + _state.update { + it.copy( + graphicsDriver = graphicsDriver, + graphicsDriverVersion = graphicsDriverVersion, + audioDriver = audioDriver, + dxwrapper = dxwrapper, + dxwrapperConfig = dxwrapperConfig, + screenSize = screenSize, + ) } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 729688576..4de977f57 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -1,17 +1,24 @@ package app.gamenative.ui.screen.library -import android.Manifest +import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.content.res.Configuration -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.BorderStroke +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.* +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -20,47 +27,47 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf 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.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -69,71 +76,30 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import app.gamenative.Constants +import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.LibraryItem -import app.gamenative.data.SteamApp import app.gamenative.service.SteamService +import app.gamenative.ui.component.GamepadAction +import app.gamenative.ui.component.GamepadActionBar +import app.gamenative.ui.component.GamepadButton import app.gamenative.ui.component.LoadingScreen -import app.gamenative.ui.component.dialog.ContainerConfigDialog -import app.gamenative.ui.component.dialog.LoadingDialog -import app.gamenative.ui.component.dialog.MessageDialog -import app.gamenative.ui.component.dialog.state.MessageDialogState import app.gamenative.ui.component.topbar.BackButton import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType -import app.gamenative.ui.enums.DialogType import app.gamenative.ui.internal.fakeAppInfo +import app.gamenative.ui.screen.library.appscreen.CustomGameAppScreen +import app.gamenative.ui.screen.library.appscreen.SteamAppScreen +import app.gamenative.ui.screen.library.components.GameOptionsPanel import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.utils.ContainerUtils -import app.gamenative.utils.StorageUtils -import com.google.android.play.core.splitcompat.SplitCompat +import app.gamenative.ui.util.AdaptiveHeroHeight import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.coil.CoilImage -import app.gamenative.utils.SteamUtils -import com.winlator.container.ContainerData -import com.winlator.xenvironment.ImageFsInstaller -import com.winlator.fexcore.FEXCoreManager -import app.gamenative.ui.screen.library.appscreen.SteamAppScreen -import app.gamenative.ui.screen.library.appscreen.CustomGameAppScreen -import app.gamenative.ui.data.GameDisplayInfo import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import timber.log.Timber -import app.gamenative.service.SteamService.Companion.getAppDirPath -import com.posthog.PostHog -import android.content.Context -import android.net.ConnectivityManager -import android.net.NetworkCapabilities -import android.os.Environment -import androidx.compose.foundation.border -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.rememberCoroutineScope -import app.gamenative.PrefManager -import app.gamenative.service.DownloadService -import java.nio.file.Paths -import kotlin.io.path.pathString import kotlin.math.roundToInt -import app.gamenative.enums.PathType -import com.winlator.container.ContainerManager -import app.gamenative.enums.SyncResult -import android.widget.Toast -import app.gamenative.enums.Marker -import androidx.compose.animation.core.* -import androidx.compose.foundation.lazy.grid.GridItemSpan -import app.gamenative.PluviaApp -import app.gamenative.events.AndroidEvent -import app.gamenative.utils.MarkerUtils -import app.gamenative.utils.createPinnedShortcut -import kotlinx.coroutines.withContext // https://partner.steamgames.com/doc/store/assets/libraryassets#4 @@ -141,7 +107,7 @@ import kotlinx.coroutines.withContext private fun SkeletonText( modifier: Modifier = Modifier, lines: Int = 1, - lineHeight: Int = 16 + lineHeight: Int = 16, ) { val infiniteTransition = rememberInfiniteTransition(label = "skeleton") val alpha by infiniteTransition.animateFloat( @@ -149,9 +115,9 @@ private fun SkeletonText( targetValue = 0.25f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1500, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse + repeatMode = RepeatMode.Reverse, ), - label = "alpha" + label = "alpha", ) val color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha) @@ -164,8 +130,8 @@ private fun SkeletonText( .height(lineHeight.dp) .background( color = color, - shape = RoundedCornerShape(4.dp) - ) + shape = RoundedCornerShape(4.dp), + ), ) if (index < lines - 1) { Spacer(modifier = Modifier.height(4.dp)) @@ -174,6 +140,215 @@ private fun SkeletonText( } } +@Composable +private fun PrimaryActionButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isInstalled: Boolean = false, + isDownloading: Boolean = false, + downloadProgress: Float = 0f, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.04f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "primaryActionScale", + ) + + val buttonColor = when { + isDownloading -> PluviaTheme.colors.statusDownloading + isInstalled -> PluviaTheme.colors.statusInstalled + else -> PluviaTheme.colors.statusAvailable + } + + Box( + modifier = modifier + .scale(scale) + .clip(RoundedCornerShape(8.dp)) + .background( + if (enabled) buttonColor else buttonColor.copy(alpha = 0.5f), + ) + .then( + if (isFocused) { + Modifier.border(2.dp, Color.White, RoundedCornerShape(8.dp)) + } else { + Modifier + }, + ) + .focusRequester(focusRequester) + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = onClick, + ) + .padding(horizontal = 24.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + if (isDownloading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(modifier = Modifier.width(100.dp)) { + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + ) + } + Text( + text = "${(downloadProgress * 100).toInt()}%", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = Color.White, + ) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = if (isInstalled) Icons.Default.PlayArrow else Icons.Default.CloudDownload, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = Color.White, + ) + } + } + } +} + +/** + * Icon-only action button for the overlay action bar + */ +@Composable +private fun ActionIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.1f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "actionIconScale", + ) + + Box( + modifier = modifier + .scale(scale) + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (isFocused) { + Color.White.copy(alpha = 0.2f) + } else { + Color.White.copy(alpha = 0.1f) + }, + ) + .then( + if (isFocused) { + Modifier.border(2.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)) + } else { + Modifier + }, + ) + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } +} + +/** + * Info card for game details with optional status indicator + */ +@Composable +private fun InfoCard( + label: String, + value: String, + modifier: Modifier = Modifier, + statusColor: Color? = null, + isCompact: Boolean = false, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(if (isCompact) 14.dp else 18.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (statusColor != null) { + Box( + modifier = Modifier + .size(10.dp) + .background(statusColor, CircleShape), + ) + Spacer(modifier = Modifier.width(10.dp)) + } + Text( + text = value, + style = if (isCompact) { + MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + } else { + MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + }, + color = if (statusColor != null) statusColor else MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppScreen( @@ -237,582 +412,531 @@ internal fun AppScreenContent( val hasInternet = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true val wifiConnected = capabilities?.run { hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || - hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } == true val wifiAllowed = !PrefManager.downloadOnWifiOnly || wifiConnected val scrollState = rememberScrollState() var optionsMenuVisible by remember { mutableStateOf(false) } + // Focus requesters for gamepad navigation + val playButtonFocusRequester = remember { FocusRequester() } + + // Calculate parallax offset based on scroll + val parallaxOffset = scrollState.value * 0.5f + LaunchedEffect(displayInfo.appId) { scrollState.animateScrollTo(0) } - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(scrollState), - horizontalAlignment = Alignment.Start, - ) { - // Hero Section with Game Image Background - Box( - modifier = Modifier - .fillMaxWidth() - .height(250.dp) - ) { - // Hero background image - if (displayInfo.heroImageUrl != null) { - CoilImage( - modifier = Modifier.fillMaxSize(), - imageModel = { displayInfo.heroImageUrl }, - imageOptions = ImageOptions(contentScale = ContentScale.Crop), - loading = { LoadingScreen() }, - failure = { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - // Gradient background as fallback - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.primary - ) { } - } - }, - previewPlaceholder = painterResource(R.drawable.testhero), - ) - } else { - // Fallback gradient background when no hero image - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.primary - ) { } - } - - // Gradient overlay - Box( - modifier = Modifier - .fillMaxSize() - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.8f) - ) - ) - ) - ) + LaunchedEffect(Unit) { + playButtonFocusRequester.requestFocus() + } - // Compatibility status overlay (bottom center) - // Must be after gradient but before title to ensure visibility - if (displayInfo.compatibilityMessage != null && displayInfo.compatibilityColor != null) { - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.4f)) - .padding(horizontal = 8.dp, vertical = 1.dp) - ) { - Text( - text = displayInfo.compatibilityMessage, - style = MaterialTheme.typography.labelSmall, - color = Color(displayInfo.compatibilityColor), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.Center) - ) + // Handle gamepad button presses + val handleKeyEvent: (KeyEvent) -> Boolean = { event -> + if (event.action == KeyEvent.ACTION_DOWN) { + when (event.keyCode) { + // SELECT button - open options menu + KeyEvent.KEYCODE_BUTTON_SELECT -> { + optionsMenuVisible = true + true } - } - // Back button (top left) - Box( - modifier = Modifier - .padding(20.dp) - .background( - color = Color.Black.copy(alpha = 0.5f), - shape = RoundedCornerShape(12.dp) - ) - ) { - BackButton(onClick = onBack) - } - - // Settings/options button (top right) - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(20.dp) - ) { - IconButton( - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.5f), - shape = RoundedCornerShape(12.dp) - ), - onClick = { optionsMenuVisible = !optionsMenuVisible }, - content = { - Icon( - Icons.Filled.MoreVert, - contentDescription = "Settings", - tint = Color.White - ) - }, - ) - - DropdownMenu( - expanded = optionsMenuVisible, - onDismissRequest = { optionsMenuVisible = false }, - ) { - optionsMenu.forEach { menuOption -> - DropdownMenuItem( - text = { Text(menuOption.optionType.text) }, - onClick = { - menuOption.onClick() - optionsMenuVisible = false - }, - ) + // START button - primary action (play/download/pause) + KeyEvent.KEYCODE_BUTTON_START -> { + if (isDownloading || hasPartialDownload) { + onPauseResumeClick() + } else { + onDownloadInstallClick() } + true } - } - // Game title and subtitle - Column( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(20.dp) - ) { - Text( - text = displayInfo.name, - style = MaterialTheme.typography.headlineLarge.copy( - fontWeight = FontWeight.Bold, - shadow = Shadow( - color = Color.Black.copy(alpha = 0.5f), - offset = Offset(0f, 2f), - blurRadius = 10f - ) - ), - color = Color.White - ) - - Text( - text = "${displayInfo.developer} • ${remember(displayInfo.releaseDate) { - if (displayInfo.releaseDate > 0) { - SimpleDateFormat("yyyy", Locale.getDefault()).format(Date(displayInfo.releaseDate * 1000)) - } else { - "" - } - }}", - style = MaterialTheme.typography.bodyMedium, - color = Color.White.copy(alpha = 0.9f) - ) - } - } - - // Content section - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - ) { - // Action buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Pause/Resume and Delete when downloading or paused - // Use hasPartialDownload from BaseAppScreen (implemented per game source) - // Disable resume when Wi-Fi only is enabled and there's no Wi-Fi - val isResume = !isDownloading && hasPartialDownload - val pauseResumeEnabled = if (isResume) wifiAllowed else true - if (isDownloading || hasPartialDownload) { - // Pause or Resume - Button( - enabled = pauseResumeEnabled, - modifier = Modifier.weight(1f), - onClick = onPauseResumeClick, - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) - ) { - Text( - text = if (isDownloading) stringResource(R.string.pause_download) - else stringResource(R.string.resume_download), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) - ) - } - // Delete (Cancel) download data - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = onDeleteDownloadClick, - shape = RoundedCornerShape(16.dp), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) - ) { - Text(stringResource(R.string.delete_app), style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)) - } - } else { - // Disable install when Wi-Fi only is enabled and there's no Wi-Fi - val isInstall = !isInstalled - val installEnabled = if (isInstall) wifiAllowed && hasInternet else true - // For installed games, button should always be enabled (regardless of isValidToDownload) - // For games that need installation, check isValidToDownload - val buttonEnabled = if (isInstalled) { - installEnabled // Installed games can always be played + // B button - back + KeyEvent.KEYCODE_BUTTON_B -> { + if (optionsMenuVisible) { + optionsMenuVisible = false } else { - installEnabled && isValidToDownload // Only check download validity when not installed - } - // Install or Play button - Button( - enabled = buttonEnabled, - modifier = Modifier.weight(1f), - onClick = { - onDownloadInstallClick() - }, - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) - ) { - val text = when { - isInstalled -> stringResource(R.string.run_app) - !hasInternet -> stringResource(R.string.library_need_internet) - !wifiConnected && PrefManager.downloadOnWifiOnly -> stringResource(R.string.library_wifi_only_enabled) - else -> stringResource(R.string.install_app) - } - Text( - text = text, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) - ) - } - // Uninstall/Delete button if already installed - // This is shared functionality - all game types show delete button when installed - // The action is handled by onDeleteDownloadClick which is implemented per game source - if (isInstalled) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { onDeleteDownloadClick() }, - shape = RoundedCornerShape(16.dp), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(16.dp) - ) { - Text( - text = stringResource(R.string.uninstall), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) - ) - } + onBack() } + true } + + else -> false } + } else { + false + } + } - Spacer(modifier = Modifier.height(32.dp)) - - // Download progress section - if (isDownloading) { - val downloadInfo = SteamService.getAppDownloadInfo(displayInfo.gameId) - val statusMessageFlow = downloadInfo?.getStatusMessageFlow() - val statusMessageState = statusMessageFlow?.collectAsState(initial = statusMessageFlow.value) - val statusMessage = statusMessageState?.value - - // Use DownloadInfo's byte-based ETA when available for more stable estimates - val timeLeftText = remember(displayInfo.appId, downloadProgress, downloadInfo, statusMessage) { - val etaMs = downloadInfo?.getEstimatedTimeRemaining() - if (etaMs != null && etaMs > 0L) { - val totalSeconds = etaMs / 1000 - val minutesLeft = totalSeconds / 60 - val secondsPart = totalSeconds % 60 - "${minutesLeft}m ${secondsPart}s left" - } else if (downloadProgress in 0f..1f && downloadProgress < 1f) { - val statusText = statusMessage?.takeUnless { it.isBlank() } - statusText ?: "Calculating..." - } else { - "" - } - } - Column( - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.installation_progress), - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "${(downloadProgress * 100f).toInt()}%", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.tertiary - ) - } + // Button state calculations + val isResume = !isDownloading && hasPartialDownload + val pauseResumeEnabled = if (isResume) wifiAllowed else true + val isInstall = !isInstalled + val installEnabled = if (isInstall) wifiAllowed && hasInternet else true + val buttonEnabled = if (isInstalled) { + installEnabled + } else { + installEnabled && isValidToDownload + } - Spacer(modifier = Modifier.height(12.dp)) + // Handle back press when options panel is open + BackHandler(enabled = optionsMenuVisible) { + optionsMenuVisible = false + } - LinearProgressIndicator( - progress = { downloadProgress }, + Box( + modifier = modifier + .fillMaxSize() + .onKeyEvent { handleKeyEvent(it.nativeKeyEvent) }, + ) { + Column(modifier = Modifier.fillMaxSize()) { + // Main scrollable content + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState), + ) { + // Hero Section (Parallax) + Box( modifier = Modifier .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(4.dp)), - color = MaterialTheme.colorScheme.tertiary, - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Show download size and ETA - val downloadingText = stringResource(R.string.downloading) - val sizeText = remember(displayInfo.gameId, downloadProgress, downloadInfo) { - val (bytesDone, bytesTotal) = downloadInfo?.getBytesProgress() ?: (0L to 0L) - if (bytesTotal > 0L) { - "${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)}" - } else if (bytesDone > 0L) { - formatBytes(bytesDone) - } else { - downloadingText - } - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + .height(AdaptiveHeroHeight.get()), ) { - Text( - text = sizeText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = timeLeftText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Spacer(modifier = Modifier.height(32.dp)) - } - } + // Hero background image + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationY = parallaxOffset + }, + ) { + if (displayInfo.heroImageUrl != null) { + CoilImage( + modifier = Modifier.fillMaxSize(), + imageModel = { displayInfo.heroImageUrl }, + imageOptions = ImageOptions(contentScale = ContentScale.Crop), + loading = { LoadingScreen() }, + failure = { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primaryContainer, + ), + ), + ), + ) + }, + previewPlaceholder = painterResource(R.drawable.testhero), + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primaryContainer, + ), + ), + ), + ) + } + } - if (isUpdatePending) { - // Update banner - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background( - brush = Brush.linearGradient( - colors = listOf( - Color(0x1A06B6D4), - Color(0x1AA21CAF) + // Gradient overlay + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.3f), + Color.Black.copy(alpha = 0.85f), + ), + startY = 0f, + endY = Float.POSITIVE_INFINITY, + ), ), - start = Offset(0f, 0f), - end = Offset(1000f, 1000f) - ) ) - .border(1.dp, MaterialTheme.colorScheme.tertiary, RoundedCornerShape(16.dp)) - .padding(20.dp) - ) { - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + + // Back button (top left) + Box( + modifier = Modifier + .padding(16.dp) + .size(44.dp) + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier - .size(24.dp) - .background(MaterialTheme.colorScheme.tertiary, CircleShape), - contentAlignment = Alignment.Center - ) { - Text("↑", color = MaterialTheme.colorScheme.onTertiary, fontSize = 14.sp) - } - Text( - stringResource(R.string.update_available), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.tertiary - ) + BackButton(onClick = onBack) } - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = onUpdateClick, - modifier = Modifier.align(Alignment.Start), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.tertiary, - contentColor = MaterialTheme.colorScheme.onTertiary - ), - contentPadding = PaddingValues(12.dp) + + // Bottom overlay with title and action bar + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), ) { - Text(stringResource(R.string.update_now), color = MaterialTheme.colorScheme.onTertiary) - } - } - } - Spacer(modifier = Modifier.height(24.dp)) - } + // Game title + Text( + text = displayInfo.name, + style = MaterialTheme.typography.headlineMedium.copy( + fontWeight = FontWeight.Bold, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.6f), + offset = Offset(0f, 2f), + blurRadius = 8f, + ), + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) - // Game information card - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.surfaceVariant) - ) { - Column(modifier = Modifier.fillMaxWidth()) { - // Colored top border - Box( - modifier = Modifier - .fillMaxWidth() - .height(3.dp) - .background( - brush = Brush.horizontalGradient( - colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary - ) - ) + // Developer and year + Text( + text = "${displayInfo.developer} • ${ + remember(displayInfo.releaseDate) { + if (displayInfo.releaseDate > 0) { + SimpleDateFormat("yyyy", Locale.getDefault()).format(Date(displayInfo.releaseDate * 1000)) + } else { + "" + } + } + }", + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.85f), ) - ) - Column(modifier = Modifier.padding(24.dp)) { - Text( - text = stringResource(R.string.game_information), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), - modifier = Modifier.padding(bottom = 16.dp) - ) + Spacer(modifier = Modifier.height(16.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - // Setting a fixed height to avoid nested scrolling issues - modifier = Modifier.height(220.dp) - ) { - // Status item - item { - Column { - Text( - text = stringResource(R.string.status), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + // Integrated action bar - overlaid on hero + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.5f)) + .padding(12.dp) + .focusGroup(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Primary action button (left-aligned) + if (isDownloading || hasPartialDownload) { + PrimaryActionButton( + text = if (isDownloading) { + stringResource(R.string.pause_download) + } else { + stringResource(R.string.resume_download) + }, + onClick = onPauseResumeClick, + enabled = pauseResumeEnabled, + isInstalled = false, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + focusRequester = playButtonFocusRequester, ) - Spacer(modifier = Modifier.height(4.dp)) - Surface( - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(8.dp) - .background( - color = MaterialTheme.colorScheme.tertiary, - shape = CircleShape - ) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = when { - isInstalled -> stringResource(R.string.installed) - isDownloading -> stringResource(R.string.installing) - else -> stringResource(R.string.not_installed) - }, - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), - color = MaterialTheme.colorScheme.tertiary - ) - } + } else { + val text = when { + isInstalled -> stringResource(R.string.run_app) + !hasInternet -> stringResource(R.string.library_need_internet) + !wifiConnected && PrefManager.downloadOnWifiOnly -> stringResource(R.string.library_wifi_only_enabled) + else -> stringResource(R.string.install_app) } + PrimaryActionButton( + text = text, + onClick = onDownloadInstallClick, + enabled = buttonEnabled, + isInstalled = isInstalled, + focusRequester = playButtonFocusRequester, + ) } - } - // Size item - item { - Column { - Text( - text = stringResource(R.string.size), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - // Show size from displayInfo - Text( - text = when { - isInstalled && displayInfo.sizeOnDisk != null -> displayInfo.sizeOnDisk - !isInstalled && displayInfo.sizeFromStore != null -> displayInfo.sizeFromStore - else -> "Unknown" + Spacer(modifier = Modifier.weight(1f)) + + // Secondary action icons (right-aligned) + ActionIconButton( + icon = Icons.Default.Settings, + contentDescription = stringResource(R.string.options), + onClick = { optionsMenuVisible = true }, + ) + + if (isInstalled) { + ActionIconButton( + icon = Icons.Default.Cloud, + contentDescription = stringResource(R.string.cloud), + onClick = { + optionsMenu.find { it.optionType == AppOptionMenuType.ForceCloudSync }?.onClick?.invoke() }, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + + if (isInstalled || hasPartialDownload) { + ActionIconButton( + icon = Icons.Default.Delete, + contentDescription = if (isInstalled) stringResource(R.string.uninstall) else stringResource(R.string.delete_app), + onClick = onDeleteDownloadClick, ) } } - // Location item - if (isInstalled) { - item (span = { GridItemSpan(maxLineSpan) }) { + // Compatibility status (if applicable) + if (displayInfo.compatibilityMessage != null && displayInfo.compatibilityColor != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = displayInfo.compatibilityMessage, + style = MaterialTheme.typography.labelSmall, + color = Color(displayInfo.compatibilityColor), + ) + } + } + } + + // Content section below hero with solid background + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(20.dp), + ) { + // Download progress section + if (isDownloading) { + val downloadInfo = SteamService.getAppDownloadInfo(displayInfo.gameId) + val statusMessageFlow = downloadInfo?.getStatusMessageFlow() + val statusMessageState = statusMessageFlow?.collectAsState(initial = statusMessageFlow.value) + val statusMessage = statusMessageState?.value + + val timeLeftText = remember(displayInfo.appId, downloadProgress, downloadInfo, statusMessage) { + val etaMs = downloadInfo?.getEstimatedTimeRemaining() + if (etaMs != null && etaMs > 0L) { + val totalSeconds = etaMs / 1000 + val minutesLeft = totalSeconds / 60 + val secondsPart = totalSeconds % 60 + "${minutesLeft}m ${secondsPart}s left" + } else if (downloadProgress in 0f..1f && downloadProgress < 1f) { + statusMessage?.takeUnless { it.isBlank() } ?: "Calculating..." + } else { + "" + } + } - Column { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.installation_progress), + style = MaterialTheme.typography.titleSmall, + ) Text( - text = stringResource(R.string.location), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "${(downloadProgress * 100f).toInt()}%", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, ) - Spacer(modifier = Modifier.height(4.dp)) - Surface( - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f)), - ) { - Text( - text = displayInfo.installLocation ?: "Unknown", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val downloadingText = stringResource(R.string.downloading) + val sizeText = remember(displayInfo.gameId, downloadProgress, downloadInfo) { + val (bytesDone, bytesTotal) = downloadInfo?.getBytesProgress() ?: (0L to 0L) + if (bytesTotal > 0L) { + "${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)}" + } else if (bytesDone > 0L) { + formatBytes(bytesDone) + } else { + downloadingText } } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = sizeText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = timeLeftText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } + Spacer(modifier = Modifier.height(16.dp)) + } - // Developer item - item { - Column { - Text( - text = stringResource(R.string.developer), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = displayInfo.developer, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + // Update available banner + if (isUpdatePending) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.update_available), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + Button( + onClick = onUpdateClick, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text(stringResource(R.string.update_now)) + } } } + Spacer(modifier = Modifier.height(16.dp)) + } + + // Game information section + Text( + text = stringResource(R.string.game_information), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(bottom = 12.dp), + ) + + // Info cards in 2-column grid + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val statusText = when { + isInstalled -> stringResource(R.string.installed) + isDownloading -> stringResource(R.string.installing) + else -> stringResource(R.string.not_installed) + } + val statusColor = when { + isInstalled -> PluviaTheme.colors.statusInstalled + isDownloading -> MaterialTheme.colorScheme.tertiary + else -> null + } + InfoCard( + label = stringResource(R.string.status), + value = statusText, + statusColor = statusColor, + isCompact = true, + modifier = Modifier.weight(1f), + ) + InfoCard( + label = stringResource(R.string.size), + value = when { + isInstalled && displayInfo.sizeOnDisk != null -> displayInfo.sizeOnDisk + !isInstalled && displayInfo.sizeFromStore != null -> displayInfo.sizeFromStore + else -> "Unknown" + }, + isCompact = true, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(10.dp)) - // Release Date item - item { - Column { - Text( - text = stringResource(R.string.release_date), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + InfoCard( + label = stringResource(R.string.developer), + value = displayInfo.developer, + isCompact = true, + modifier = Modifier.weight(1f), + ) + InfoCard( + label = stringResource(R.string.release_date), + value = remember(displayInfo.releaseDate) { + if (displayInfo.releaseDate > 0) { + SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + .format(Date(displayInfo.releaseDate * 1000)) + } else { + "Unknown" + } + }, + isCompact = true, + modifier = Modifier.weight(1f), + ) + } + + // Install location (when installed) + if (isInstalled && displayInfo.installLocation != null) { + Spacer(modifier = Modifier.height(10.dp)) + InfoCard( + label = stringResource(R.string.location), + value = displayInfo.installLocation, + isCompact = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + // Play time and last played + if (displayInfo.playtimeText != null || displayInfo.lastPlayedText != null) { + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (displayInfo.playtimeText != null) { + InfoCard( + label = stringResource(R.string.play_time), + value = displayInfo.playtimeText, + isCompact = true, + modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = remember(displayInfo.releaseDate) { - if (displayInfo.releaseDate > 0) { - val date = Date(displayInfo.releaseDate * 1000) - SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date) - } else { - "Unknown" - } - }, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + } + if (displayInfo.lastPlayedText != null) { + InfoCard( + label = stringResource(R.string.last_played), + value = displayInfo.lastPlayedText, + isCompact = true, + modifier = Modifier.weight(1f), ) } } @@ -820,7 +944,56 @@ internal fun AppScreenContent( } } } + + GamepadActionBar( + actions = listOf( + if (isInstalled) { + GamepadAction( + button = GamepadButton.START, + labelResId = R.string.run_app, + onClick = onDownloadInstallClick, + ) + } else if (isDownloading) { + GamepadAction( + button = GamepadButton.START, + labelResId = R.string.pause_download, + onClick = onPauseResumeClick, + ) + } else if (hasPartialDownload) { + GamepadAction( + button = GamepadButton.START, + labelResId = R.string.resume_download, + onClick = onPauseResumeClick, + ) + } else { + GamepadAction( + button = GamepadButton.START, + labelResId = R.string.install_app, + onClick = onDownloadInstallClick, + ) + }, + GamepadAction( + button = GamepadButton.SELECT, + labelResId = R.string.options, + onClick = { optionsMenuVisible = true }, + ), + GamepadAction( + button = GamepadButton.B, + labelResId = R.string.back, + onClick = onBack, + ), + ), + visible = !optionsMenuVisible, + ) } + + // Options panel - slides in from right + GameOptionsPanel( + isOpen = optionsMenuVisible, + onDismiss = { optionsMenuVisible = false }, + options = optionsMenu.toList(), + modifier = Modifier.align(Alignment.CenterEnd), + ) } } @@ -876,7 +1049,6 @@ internal fun GameMigrationDialog( ) } - /*********** * PREVIEW * ***********/ @@ -932,4 +1104,3 @@ private fun Preview_AppScreen() { } } } - diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index b0d94ff50..5374241f4 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -1,89 +1,80 @@ package app.gamenative.ui.screen.library import android.content.res.Configuration +import android.view.KeyEvent import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.displayCutoutPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.setValue -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.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.dp -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.gamenative.PrefManager import app.gamenative.R -import app.gamenative.data.LibraryItem +import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource -import app.gamenative.service.SteamService +import app.gamenative.data.LibraryItem +import app.gamenative.ui.component.GamepadAction +import app.gamenative.ui.component.GamepadActionBar +import app.gamenative.ui.component.GamepadButton +import app.gamenative.ui.component.LibraryActions +import app.gamenative.ui.components.rememberCustomGameFolderPicker +import app.gamenative.ui.components.requestPermissionsForPath import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter -import app.gamenative.ui.enums.Orientation -import app.gamenative.events.AndroidEvent -import app.gamenative.PluviaApp -import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.ui.enums.LibraryTab +import app.gamenative.ui.enums.LibraryTab.Companion.next +import app.gamenative.ui.enums.LibraryTab.Companion.previous +import app.gamenative.ui.enums.PaneType +import app.gamenative.ui.enums.SortOption import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.model.LibraryViewModel import app.gamenative.ui.screen.library.components.LibraryDetailPane import app.gamenative.ui.screen.library.components.LibraryListPane +import app.gamenative.ui.screen.library.components.LibraryOptionsPanel +import app.gamenative.ui.screen.library.components.LibrarySearchBar +import app.gamenative.ui.screen.library.components.LibraryTabBar +import app.gamenative.ui.screen.library.components.SystemMenu import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.components.rememberCustomGameFolderPicker -import app.gamenative.ui.components.requestPermissionsForPath import app.gamenative.utils.CustomGameScanner -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.EnumSet @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -98,7 +89,6 @@ fun HomeLibraryScreen( val state by viewModel.state.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - LibraryScreenContent( state = state, listState = viewModel.listState, @@ -115,6 +105,9 @@ fun HomeLibraryScreen( onGoOnline = onGoOnline, onSourceToggle = viewModel::onSourceToggle, onAddCustomGameFolder = viewModel::addCustomGameFolder, + onSortOptionChanged = viewModel::onSortOptionChanged, + onOptionsPanelToggle = viewModel::onOptionsPanelToggle, + onTabChanged = viewModel::onTabChanged, isOffline = isOffline, ) } @@ -137,11 +130,25 @@ private fun LibraryScreenContent( onGoOnline: () -> Unit, onSourceToggle: (GameSource) -> Unit, onAddCustomGameFolder: (String) -> Unit, + onSortOptionChanged: (SortOption) -> Unit, + onOptionsPanelToggle: (Boolean) -> Unit, + onTabChanged: (LibraryTab) -> Unit, isOffline: Boolean = false, ) { val context = LocalContext.current var selectedAppId by remember { mutableStateOf(null) } - val filterFabExpanded by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } + val isViewWide = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + var currentPaneType by remember { mutableStateOf(PrefManager.libraryLayout) } + + // Initialize layout if undecided + LaunchedEffect(Unit) { + if (currentPaneType == PaneType.UNDECIDED) { + currentPaneType = if (isViewWide) PaneType.GRID_HERO else PaneType.GRID_CAPSULE + PrefManager.libraryLayout = currentPaneType + } + } + + var isSystemMenuOpen by remember { mutableStateOf(false) } // Dialog state for add custom game prompt var showAddCustomGameDialog by remember { mutableStateOf(false) } @@ -184,7 +191,22 @@ private fun LibraryScreenContent( } } - BackHandler(selectedAppId != null) { selectedAppId = null } + BackHandler(enabled = isSystemMenuOpen) { + isSystemMenuOpen = false + } + + BackHandler(enabled = state.isOptionsPanelOpen) { + onOptionsPanelToggle(false) + } + + BackHandler(enabled = state.isSearching && selectedAppId == null) { + onIsSearching(false) + onSearchQuery("") + } + + BackHandler(enabled = selectedAppId != null) { + selectedAppId = null + } // Refresh list when navigating back from detail view LaunchedEffect(selectedAppId) { @@ -212,29 +234,135 @@ private fun LibraryScreenContent( // List page keeps safe cutout padding (for notches) Modifier.displayCutoutPadding() } - } else Modifier + } else { + Modifier + } Box( - Modifier.background(MaterialTheme.colorScheme.background) - .then(safePaddingModifier)) { + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .then(safePaddingModifier) + .onPreviewKeyEvent { keyEvent -> + // TODO: consider abstracting this + // Handle gamepad buttons + if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) { + when (keyEvent.nativeKeyEvent.keyCode) { + // L1 button - previous tab + KeyEvent.KEYCODE_BUTTON_L1 -> { + if (selectedAppId == null && !state.isOptionsPanelOpen && !isSystemMenuOpen) { + onTabChanged(state.currentTab.previous()) + true + } else { + false + } + } + + // R1 button - next tab + KeyEvent.KEYCODE_BUTTON_R1 -> { + if (selectedAppId == null && !state.isOptionsPanelOpen && !isSystemMenuOpen) { + onTabChanged(state.currentTab.next()) + true + } else { + false + } + } + + // SELECT button - toggle options panel (library filters/sort) + KeyEvent.KEYCODE_BUTTON_SELECT -> { + if (selectedAppId == null && !isSystemMenuOpen) { + onOptionsPanelToggle(!state.isOptionsPanelOpen) + true + } else { + false + } + } + + // START button - toggle system menu (profile/settings) + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_MENU, + -> { + if (selectedAppId == null && !state.isOptionsPanelOpen) { + isSystemMenuOpen = !isSystemMenuOpen + true + } else { + false + } + } + + // Y button - toggle search + KeyEvent.KEYCODE_BUTTON_Y -> { + if (selectedAppId == null && !state.isOptionsPanelOpen && !isSystemMenuOpen) { + onIsSearching(!state.isSearching) + true + } else { + false + } + } + + // X button - add custom game + KeyEvent.KEYCODE_BUTTON_X -> { + if (selectedAppId == null && !state.isSearching && !state.isOptionsPanelOpen && !isSystemMenuOpen) { + onAddCustomGameClick() + true + } else { + false + } + } + + else -> false + } + } else { + false + } + }, + ) { if (selectedAppId == null) { - LibraryListPane( - state = state, - listState = listState, - sheetState = sheetState, - onFilterChanged = onFilterChanged, - onPageChange = onPageChange, - onModalBottomSheet = onModalBottomSheet, - onIsSearching = onIsSearching, - onSearchQuery = onSearchQuery, - onNavigateRoute = onNavigateRoute, - onLogout = onLogout, - onNavigate = { appId -> selectedAppId = appId }, - onGoOnline = onGoOnline, - onRefresh = onRefresh, - onSourceToggle = onSourceToggle, - isOffline = isOffline, - ) + // Use Box to allow content to scroll behind the tab bar + Box(modifier = Modifier.fillMaxSize()) { + // Library list (content scrolls behind tab bar) + LibraryListPane( + state = state, + listState = listState, + currentLayout = currentPaneType, + onPageChange = onPageChange, + onNavigate = { appId -> selectedAppId = appId }, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize(), + ) + + // Top overlay: Tab bar OR Search bar + if (state.isSearching) { + // Search overlay replaces tab bar when searching + // TODO: Gamepad focus is a bit wonky whenever we show the search bar + LibrarySearchBar( + isVisible = true, + searchQuery = state.searchQuery, + resultCount = state.totalAppsInFilter, + listState = listState, + onSearchQuery = onSearchQuery, + onDismiss = { onIsSearching(false) }, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } else { + // Tab bar when not searching + LibraryTabBar( + currentTab = state.currentTab, + onTabSelected = onTabChanged, + onPreviousTab = { onTabChanged(state.currentTab.previous()) }, + onNextTab = { onTabChanged(state.currentTab.next()) }, + onOptionsClick = { onOptionsPanelToggle(true) }, + onSearchClick = { onIsSearching(true) }, + onAddGameClick = onAddCustomGameClick, + onMenuClick = { isSystemMenuOpen = true }, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth(), + ) + } + } } else { // Find the LibraryItem from the state based on selectedAppId val selectedLibraryItem = selectedAppId?.let { appId -> @@ -252,35 +380,78 @@ private fun LibraryScreenContent( ) } + // Bottom action bar + if (selectedAppId == null && !state.isOptionsPanelOpen && !isSystemMenuOpen) { + val libraryActions = if (state.isSearching) { + listOf( + LibraryActions.select, + GamepadAction( + button = GamepadButton.B, + labelResId = R.string.back, + onClick = { + onIsSearching(false) + onSearchQuery("") + }, + ), + ) + } else { + listOf( + LibraryActions.select, + GamepadAction( + button = GamepadButton.SELECT, + labelResId = R.string.options, + onClick = { onOptionsPanelToggle(true) }, + ), + GamepadAction( + button = GamepadButton.START, + labelResId = R.string.action_system, + onClick = { isSystemMenuOpen = true }, + ), + GamepadAction( + button = GamepadButton.Y, + labelResId = R.string.search, + onClick = { onIsSearching(true) }, + ), + GamepadAction( + button = GamepadButton.X, + labelResId = R.string.action_add_game, + onClick = onAddCustomGameClick, + ), + ) + } + + GamepadActionBar( + actions = libraryActions, + modifier = Modifier.align(Alignment.BottomCenter), + visible = true, + ) + } + + // Options panel (SELECT) - renders on top of everything if (selectedAppId == null) { - Row( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(bottom = 24.dp, end = 24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (!state.isSearching) { - ExtendedFloatingActionButton( - text = { Text(text = stringResource(R.string.library_filters)) }, - icon = { Icon(imageVector = Icons.Default.FilterList, contentDescription = null) }, - expanded = filterFabExpanded, - onClick = { onModalBottomSheet(true) }, - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) - } + LibraryOptionsPanel( + isOpen = state.isOptionsPanelOpen, + onDismiss = { onOptionsPanelToggle(false) }, + selectedFilters = state.appInfoSortType, + onFilterChanged = onFilterChanged, + currentSortOption = state.currentSortOption, + onSortOptionChanged = onSortOptionChanged, + currentView = currentPaneType, + onViewChanged = { newPaneType -> + PrefManager.libraryLayout = newPaneType + currentPaneType = newPaneType + }, + ) - FloatingActionButton( - onClick = onAddCustomGameClick, - containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.add_custom_game_content_desc), - ) - } - } + // System menu (START) - renders on top of everything + SystemMenu( + isOpen = isSystemMenuOpen, + onDismiss = { isSystemMenuOpen = false }, + onNavigateRoute = onNavigateRoute, + onLogout = onLogout, + onGoOnline = onGoOnline, + isOffline = isOffline, + ) } // Add custom game dialog @@ -292,20 +463,20 @@ private fun LibraryScreenContent( Column { Text( text = stringResource(R.string.add_custom_game_dialog_message), - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = dontShowAgain, - onCheckedChange = { dontShowAgain = it } + onCheckedChange = { dontShowAgain = it }, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = stringResource(R.string.add_custom_game_dont_show_again), - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) } } @@ -318,18 +489,18 @@ private fun LibraryScreenContent( } showAddCustomGameDialog = false folderPicker.launchPicker() - } + }, ) { Text("OK") } }, dismissButton = { TextButton( - onClick = { showAddCustomGameDialog = false } + onClick = { showAddCustomGameDialog = false }, ) { Text("Cancel") } - } + }, ) } } @@ -397,6 +568,13 @@ private fun Preview_LibraryScreenContent() { onGoOnline = {}, onSourceToggle = {}, onAddCustomGameFolder = {}, + onSortOptionChanged = {}, + onOptionsPanelToggle = { isOpen -> + state = state.copy(isOptionsPanelOpen = isOpen) + }, + onTabChanged = { tab -> + state = state.copy(currentTab = tab) + }, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index c861d137c..230062b22 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -11,16 +11,18 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.* +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.content.ContextCompat +import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.LibraryItem import app.gamenative.enums.Marker import app.gamenative.enums.PathType import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent -import app.gamenative.PluviaApp import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirPath @@ -34,24 +36,22 @@ import app.gamenative.ui.screen.library.GameMigrationDialog import app.gamenative.utils.BestConfigService import app.gamenative.utils.ContainerUtils import app.gamenative.utils.MarkerUtils -import app.gamenative.utils.StorageUtils import app.gamenative.utils.SteamUtils -import com.posthog.PostHog +import app.gamenative.utils.StorageUtils import com.google.android.play.core.splitcompat.SplitCompat +import com.posthog.PostHog import com.winlator.container.ContainerData import com.winlator.container.ContainerManager +import com.winlator.core.GPUInformation import com.winlator.fexcore.FEXCoreManager import com.winlator.xenvironment.ImageFsInstaller +import java.nio.file.Paths +import kotlin.io.path.pathString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshotFlow -import com.winlator.core.GPUInformation import timber.log.Timber -import java.nio.file.Paths -import kotlin.io.path.pathString private data class InstallSizeInfo( val downloadSize: String, @@ -66,7 +66,7 @@ private fun buildInstallPromptState(context: Context, info: InstallSizeInfo): Me R.string.steam_install_space_prompt, info.downloadSize, info.installSize, - info.availableSpace + info.availableSpace, ) return MessageDialogState( visible = true, @@ -82,7 +82,7 @@ private fun buildNotEnoughSpaceState(context: Context, info: InstallSizeInfo): M val message = context.getString( R.string.steam_install_not_enough_space, info.installSize, - info.availableSpace + info.availableSpace, ) return MessageDialogState( visible = true, @@ -145,10 +145,11 @@ class SteamAppScreen : BaseAppScreen() { return pendingUpdateVerifyOperations[gameId] } } + @Composable override fun getGameDisplayInfo( context: Context, - libraryItem: LibraryItem + libraryItem: LibraryItem, ): GameDisplayInfo { val gameId = libraryItem.gameId val appInfo = remember(libraryItem.appId) { @@ -193,7 +194,9 @@ class SteamAppScreen : BaseAppScreen() { val installLocation = remember(isInstalled, gameId) { if (isInstalled) { getAppDirPath(gameId) - } else null + } else { + null + } } // Get size on disk (async, will update via state) @@ -246,7 +249,9 @@ class SteamAppScreen : BaseAppScreen() { val game = games.firstOrNull { it.appId == gameId } playtimeText = if (game != null) { SteamUtils.formatPlayTime(game.playtimeForever) + " hrs" - } else "0 hrs" + } else { + "0 hrs" + } } } @@ -320,7 +325,7 @@ class SteamAppScreen : BaseAppScreen() { libraryItem: LibraryItem, onStateChanged: () -> Unit, onProgressChanged: (Float) -> Unit, - onHasPartialDownloadChanged: ((Boolean) -> Unit)? + onHasPartialDownloadChanged: ((Boolean) -> Unit)?, ): (() -> Unit)? { val appId = libraryItem.gameId val disposables = mutableListOf<() -> Unit>() @@ -370,7 +375,7 @@ class SteamAppScreen : BaseAppScreen() { private fun attachDownloadProgressListener( appId: Int, - onProgressChanged: (Float) -> Unit + onProgressChanged: (Float) -> Unit, ): (() -> Unit)? { val downloadInfo = SteamService.getAppDownloadInfo(appId) ?: return null val listener: (Float) -> Unit = { progress -> @@ -396,13 +401,13 @@ class SteamAppScreen : BaseAppScreen() { override fun onRunContainerClick( context: Context, libraryItem: LibraryItem, - onClickPlay: (Boolean) -> Unit + onClickPlay: (Boolean) -> Unit, ) { val gameId = libraryItem.gameId val appInfo = SteamService.getAppInfoOf(gameId) PostHog.capture( event = "container_opened", - properties = mapOf("game_name" to (appInfo?.name ?: "")) + properties = mapOf("game_name" to (appInfo?.name ?: "")), ) super.onRunContainerClick(context, libraryItem, onClickPlay) } @@ -410,7 +415,7 @@ class SteamAppScreen : BaseAppScreen() { override fun onDownloadInstallClick( context: Context, libraryItem: LibraryItem, - onClickPlay: (Boolean) -> Unit + onClickPlay: (Boolean) -> Unit, ) { val gameId = libraryItem.gameId val downloadInfo = SteamService.getAppDownloadInfo(gameId) @@ -428,7 +433,7 @@ class SteamAppScreen : BaseAppScreen() { message = context.getString(R.string.steam_cancel_download_message), confirmBtnText = context.getString(R.string.yes), dismissBtnText = context.getString(R.string.no), - ) + ), ) } else if (SteamService.hasPartialDownload(gameId)) { // Resume incomplete download @@ -446,14 +451,14 @@ class SteamAppScreen : BaseAppScreen() { title = context.getString(R.string.download_prompt_title), message = context.getString(R.string.calculating_space_requirements), dismissBtnText = context.getString(R.string.cancel), - ) + ), ) } else { // Already installed: launch app val appInfo = SteamService.getAppInfoOf(gameId) PostHog.capture( event = "game_launched", - properties = mapOf("game_name" to (appInfo?.name ?: "")) + properties = mapOf("game_name" to (appInfo?.name ?: "")), ) onClickPlay(false) } @@ -489,8 +494,8 @@ class SteamAppScreen : BaseAppScreen() { title = context.getString(R.string.cancel_download_prompt_title), message = context.getString(R.string.steam_delete_download_message), confirmBtnText = context.getString(R.string.yes), - dismissBtnText = context.getString(R.string.no) - ) + dismissBtnText = context.getString(R.string.no), + ), ) } else if (isInstalled) { // Show uninstall dialog when installed @@ -511,7 +516,7 @@ class SteamAppScreen : BaseAppScreen() { override fun getEditContainerOption( context: Context, libraryItem: LibraryItem, - onEditContainer: () -> Unit + onEditContainer: () -> Unit, ): AppMenuOption { val gameId = libraryItem.gameId val appId = libraryItem.appId @@ -533,7 +538,7 @@ class SteamAppScreen : BaseAppScreen() { message = context.getString(R.string.steam_imagefs_download_install_message), confirmBtnText = context.getString(R.string.proceed), dismissBtnText = context.getString(R.string.cancel), - ) + ), ) } else { showInstallDialog( @@ -545,13 +550,13 @@ class SteamAppScreen : BaseAppScreen() { message = context.getString(R.string.steam_imagefs_install_message), confirmBtnText = context.getString(R.string.proceed), dismissBtnText = context.getString(R.string.cancel), - ) + ), ) } } else { onEditContainer() } - } + }, ) } @@ -561,7 +566,7 @@ class SteamAppScreen : BaseAppScreen() { @Composable override fun getResetContainerOption( context: Context, - libraryItem: LibraryItem + libraryItem: LibraryItem, ): AppMenuOption { val gameId = libraryItem.gameId var showResetConfirmDialog by remember { mutableStateOf(false) } @@ -572,13 +577,13 @@ class SteamAppScreen : BaseAppScreen() { showResetConfirmDialog = false resetContainerToDefaults(context, libraryItem) }, - onDismiss = { showResetConfirmDialog = false } + onDismiss = { showResetConfirmDialog = false }, ) } return AppMenuOption( AppOptionMenuType.ResetToDefaults, - onClick = { showResetConfirmDialog = true } + onClick = { showResetConfirmDialog = true }, ) } @@ -592,7 +597,7 @@ class SteamAppScreen : BaseAppScreen() { onEditContainer: () -> Unit, onBack: () -> Unit, onClickPlay: (Boolean) -> Unit, - isInstalled: Boolean + isInstalled: Boolean, ): List { val gameId = libraryItem.gameId val appId = libraryItem.appId @@ -629,7 +634,7 @@ class SteamAppScreen : BaseAppScreen() { message = context.getString(R.string.steam_verify_files_message), confirmBtnText = context.getString(R.string.steam_continue), dismissBtnText = context.getString(R.string.cancel), - ) + ), ) }, ), @@ -647,7 +652,7 @@ class SteamAppScreen : BaseAppScreen() { message = context.getString(R.string.steam_update_message), confirmBtnText = context.getString(R.string.steam_continue), dismissBtnText = context.getString(R.string.cancel), - ) + ), ) }, ), @@ -658,7 +663,7 @@ class SteamAppScreen : BaseAppScreen() { onClick = { PostHog.capture( event = "cloud_sync_forced", - properties = mapOf("game_name" to appInfo.name) + properties = mapOf("game_name" to appInfo.name), ) CoroutineScope(Dispatchers.IO).launch { val steamId = SteamService.userSteamId @@ -667,7 +672,7 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.steam_not_logged_in), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } return@launch @@ -682,7 +687,7 @@ class SteamAppScreen : BaseAppScreen() { } val syncResult = SteamService.forceSyncUserFiles( appId = gameId, - prefixToPath = prefixToPath + prefixToPath = prefixToPath, ).await() withContext(Dispatchers.Main) { @@ -691,30 +696,32 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.steam_cloud_sync_success), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } + SyncResult.UpToDate -> { Toast.makeText( context, context.getString(R.string.steam_cloud_sync_up_to_date), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } + else -> { Toast.makeText( context, context.getString( R.string.steam_cloud_sync_failed, - syncResult.syncResult + syncResult.syncResult, ), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } } } - } + }, ), AppMenuOption( AppOptionMenuType.UseKnownConfig, @@ -732,13 +739,13 @@ class SteamAppScreen : BaseAppScreen() { context, bestConfig.bestConfig, bestConfig.matchType, - true // applyKnownConfig=true to get all fields + true, // applyKnownConfig=true to get all fields ) if (parsedConfig != null && parsedConfig.isNotEmpty()) { val updatedContainerData = ContainerUtils.applyBestConfigMapToContainerData( containerData, - parsedConfig + parsedConfig, ) ContainerUtils.applyToContainer(context, container, updatedContainerData) @@ -746,7 +753,7 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.best_config_applied_successfully), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } else { @@ -754,7 +761,7 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.best_config_known_config_invalid), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } @@ -763,7 +770,7 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.best_config_no_config_available), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } @@ -773,13 +780,13 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.best_config_apply_failed, e.message ?: "Unknown error"), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } } - } - ) + }, + ), ) } @@ -801,7 +808,7 @@ class SteamAppScreen : BaseAppScreen() { libraryItem: LibraryItem, onDismiss: () -> Unit, onEditContainer: () -> Unit, - onBack: () -> Unit + onBack: () -> Unit, ) { val context = LocalContext.current val gameId = libraryItem.gameId @@ -844,11 +851,11 @@ class SteamAppScreen : BaseAppScreen() { val initialStoragePermissionGranted = remember { val writePermissionGranted = ContextCompat.checkSelfPermission( context, - Manifest.permission.WRITE_EXTERNAL_STORAGE + Manifest.permission.WRITE_EXTERNAL_STORAGE, ) == PackageManager.PERMISSION_GRANTED val readPermissionGranted = ContextCompat.checkSelfPermission( context, - Manifest.permission.READ_EXTERNAL_STORAGE + Manifest.permission.READ_EXTERNAL_STORAGE, ) == PackageManager.PERMISSION_GRANTED writePermissionGranted && readPermissionGranted } @@ -880,7 +887,7 @@ class SteamAppScreen : BaseAppScreen() { // Permission launcher for storage permissions val permissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestMultiplePermissions() + contract = ActivityResultContracts.RequestMultiplePermissions(), ) { permissions -> val writePermissionGranted = permissions[Manifest.permission.WRITE_EXTERNAL_STORAGE] ?: false val readPermissionGranted = permissions[Manifest.permission.READ_EXTERNAL_STORAGE] ?: false @@ -891,7 +898,7 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.steam_storage_permission_required), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() hideInstallDialog(gameId) } @@ -958,11 +965,12 @@ class SteamAppScreen : BaseAppScreen() { } val onConfirmClick: (() -> Unit)? = when (installDialogState.type) { DialogType.INSTALL_APP_PENDING -> null + DialogType.INSTALL_APP -> { { PostHog.capture( event = "game_install_started", - properties = mapOf("game_name" to (appInfo?.name ?: "")) + properties = mapOf("game_name" to (appInfo?.name ?: "")), ) hideInstallDialog(gameId) CoroutineScope(Dispatchers.IO).launch { @@ -970,16 +978,18 @@ class SteamAppScreen : BaseAppScreen() { } } } + DialogType.NOT_ENOUGH_SPACE -> { { hideInstallDialog(gameId) } } + DialogType.CANCEL_APP_DOWNLOAD -> { { PostHog.capture( event = "game_install_cancelled", - properties = mapOf("game_name" to (appInfo?.name ?: "")) + properties = mapOf("game_name" to (appInfo?.name ?: "")), ) val downloadInfo = SteamService.getAppDownloadInfo(gameId) downloadInfo?.cancel() @@ -992,6 +1002,7 @@ class SteamAppScreen : BaseAppScreen() { } } } + DialogType.UPDATE_VERIFY_CONFIRM -> { { hideInstallDialog(gameId) @@ -1015,14 +1026,14 @@ class SteamAppScreen : BaseAppScreen() { SteamService.forceSyncUserFiles( appId = gameId, prefixToPath = prefixToPath, - overrideLocalChangeNumber = -1 + overrideLocalChangeNumber = -1, ).await() } else { withContext(Dispatchers.Main) { Toast.makeText( context, context.getString(R.string.steam_not_logged_in), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } @@ -1034,6 +1045,7 @@ class SteamAppScreen : BaseAppScreen() { } } } + DialogType.INSTALL_IMAGEFS -> { { hideInstallDialog(gameId) @@ -1049,7 +1061,7 @@ class SteamAppScreen : BaseAppScreen() { onDownloadProgress = { /* TODO: Update loading dialog progress */ }, this, variant = variant, - context = context + context = context, ).await() } if (!SteamService.isImageFsInstalled(context)) { @@ -1067,7 +1079,7 @@ class SteamAppScreen : BaseAppScreen() { Toast.makeText( context, context.getString(R.string.steam_imagefs_installed), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } catch (e: Exception) { @@ -1076,15 +1088,16 @@ class SteamAppScreen : BaseAppScreen() { context, context.getString( R.string.steam_imagefs_install_failed, - e.message ?: "" + e.message ?: "", ), - Toast.LENGTH_LONG + Toast.LENGTH_LONG, ).show() } } } } } + else -> null } @@ -1111,8 +1124,8 @@ class SteamAppScreen : BaseAppScreen() { Text( text = stringResource( R.string.steam_uninstall_confirmation_message, - appInfo?.name ?: libraryItem.name - ) + appInfo?.name ?: libraryItem.name, + ), ) }, confirmButton = { @@ -1132,24 +1145,24 @@ class SteamAppScreen : BaseAppScreen() { context, context.getString( R.string.steam_uninstall_success, - appInfo?.name ?: libraryItem.name + appInfo?.name ?: libraryItem.name, ), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() PostHog.capture( event = "game_uninstalled", - properties = mapOf("game_name" to (appInfo?.name ?: "")) + properties = mapOf("game_name" to (appInfo?.name ?: "")), ) } else { Toast.makeText( context, context.getString(R.string.steam_uninstall_failed), - Toast.LENGTH_SHORT + Toast.LENGTH_SHORT, ).show() } } } - } + }, ) { Text(stringResource(R.string.uninstall), color = androidx.compose.material3.MaterialTheme.colorScheme.error) } @@ -1160,7 +1173,7 @@ class SteamAppScreen : BaseAppScreen() { }) { Text(stringResource(R.string.cancel)) } - } + }, ) } @@ -1175,4 +1188,3 @@ class SteamAppScreen : BaseAppScreen() { } } } - diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt new file mode 100644 index 000000000..1f198f3d8 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt @@ -0,0 +1,390 @@ +package app.gamenative.ui.screen.library.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.AddToHomeScreen +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.SdStorage +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.enums.AppOptionMenuType +import app.gamenative.ui.util.adaptivePanelWidth + +@Composable +fun GameOptionsPanel( + isOpen: Boolean, + onDismiss: () -> Unit, + options: List, + modifier: Modifier = Modifier, +) { + val firstItemFocusRequester = remember { FocusRequester() } + + LaunchedEffect(isOpen) { + if (isOpen) { + try { + firstItemFocusRequester.requestFocus() + } catch (_: Exception) { + // Focus request may fail if composition is not ready + } + } + } + + AnimatedVisibility( + visible = isOpen, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .selectable( + selected = false, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + ) + } + + AnimatedVisibility( + visible = isOpen, + enter = slideInHorizontally( + initialOffsetX = { it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + fadeIn(), + exit = slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = spring(stiffness = Spring.StiffnessHigh), + ) + fadeOut(), + modifier = modifier + .fillMaxHeight() + .width(adaptivePanelWidth(360.dp)), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + MaterialTheme.colorScheme.surface, + ), + ), + ) + .padding(vertical = 24.dp) + .verticalScroll(rememberScrollState()) + .focusGroup(), + ) { + Text( + text = stringResource(R.string.game_options_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val groupedOptionsByCategory = groupOptions(options) + + // Map category keys to localized strings + val categoryLabels = mapOf( + OptionCategory.QUICK_ACTIONS to stringResource(R.string.game_options_quick_actions), + OptionCategory.GAME_MANAGEMENT to stringResource(R.string.game_options_game_management), + OptionCategory.CONTAINER to stringResource(R.string.game_options_container), + OptionCategory.CLOUD_SAVES to stringResource(R.string.game_options_cloud_saves), + OptionCategory.HELP_INFO to stringResource(R.string.game_options_help_info), + ) + + var isFirstItem = true + groupedOptionsByCategory.forEach { (category, categoryOptions) -> + if (categoryOptions.isNotEmpty()) { + OptionSection( + title = categoryLabels[category] ?: category.name, + options = categoryOptions, + onOptionClick = { option -> + option.onClick() + onDismiss() + }, + firstItemFocusRequester = if (isFirstItem) firstItemFocusRequester else null, + ) + isFirstItem = false + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } +} + +private enum class OptionCategory { + QUICK_ACTIONS, + GAME_MANAGEMENT, + CONTAINER, + CLOUD_SAVES, + HELP_INFO, +} + +@Composable +private fun OptionSection( + title: String, + options: List, + onOptionClick: (AppMenuOption) -> Unit, + firstItemFocusRequester: FocusRequester? = null, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + + options.forEachIndexed { index, option -> + OptionItem( + option = option, + onClick = { onOptionClick(option) }, + focusRequester = if (index == 0) firstItemFocusRequester else null, + ) + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } +} + +@Composable +private fun OptionItem( + option: AppMenuOption, + onClick: () -> Unit, + focusRequester: FocusRequester? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.02f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "optionScale", + ) + + val icon = getIconForOption(option.optionType) + val isDestructive = option.optionType == AppOptionMenuType.Uninstall || + option.optionType == AppOptionMenuType.ResetToDefaults || + option.optionType == AppOptionMenuType.ResetDrm + + Row( + modifier = Modifier + .fillMaxWidth() + .scale(scale) + .padding(horizontal = 16.dp, vertical = 2.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + if (isFocused) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + } else { + Color.Transparent + }, + ) + .then( + if (isFocused) { + Modifier.border( + 1.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + RoundedCornerShape(12.dp), + ) + } else { + Modifier + }, + ) + .then( + if (focusRequester != null) { + Modifier.focusRequester(focusRequester) + } else { + Modifier + }, + ) + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = when { + isDestructive -> MaterialTheme.colorScheme.error + isFocused -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(24.dp), + ) + + Text( + text = option.optionType.text, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isFocused) FontWeight.Medium else FontWeight.Normal, + color = when { + isDestructive -> MaterialTheme.colorScheme.error + isFocused -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface + }, + ) + } +} + +private fun getIconForOption(type: AppOptionMenuType): ImageVector { + return when (type) { + AppOptionMenuType.StorePage -> Icons.AutoMirrored.Filled.OpenInNew + AppOptionMenuType.CreateShortcut -> Icons.AutoMirrored.Filled.AddToHomeScreen + AppOptionMenuType.ExportFrontend -> Icons.Default.Share + AppOptionMenuType.RunContainer -> Icons.Default.PlayArrow + AppOptionMenuType.EditContainer -> Icons.Default.Settings + AppOptionMenuType.ResetToDefaults -> Icons.Default.RestartAlt + AppOptionMenuType.GetSupport -> Icons.AutoMirrored.Filled.Help + AppOptionMenuType.SubmitFeedback -> Icons.Default.Feedback + AppOptionMenuType.ResetDrm -> Icons.Default.Key + AppOptionMenuType.UseKnownConfig -> Icons.Default.Build + AppOptionMenuType.Uninstall -> Icons.Default.Delete + AppOptionMenuType.VerifyFiles -> Icons.Default.VerifiedUser + AppOptionMenuType.Update -> Icons.Default.Update + AppOptionMenuType.MoveToExternalStorage -> Icons.Default.SdStorage + AppOptionMenuType.MoveToInternalStorage -> Icons.Default.Storage + AppOptionMenuType.ForceCloudSync -> Icons.Default.Sync + AppOptionMenuType.ForceDownloadRemote -> Icons.Default.CloudDownload + AppOptionMenuType.ForceUploadLocal -> Icons.Default.CloudUpload + AppOptionMenuType.FetchSteamGridDBImages -> Icons.Default.Image + } +} + +private fun groupOptions(options: List): Map> { + val quickActions = mutableListOf() + val gameManagement = mutableListOf() + val containerSettings = mutableListOf() + val cloudSaves = mutableListOf() + val helpInfo = mutableListOf() + + options.forEach { option -> + when (option.optionType) { + // Quick Actions + AppOptionMenuType.RunContainer, + AppOptionMenuType.CreateShortcut, + AppOptionMenuType.ExportFrontend, + -> quickActions.add(option) + + // Game Management + AppOptionMenuType.Uninstall, + AppOptionMenuType.VerifyFiles, + AppOptionMenuType.Update, + AppOptionMenuType.MoveToExternalStorage, + AppOptionMenuType.MoveToInternalStorage, + -> gameManagement.add(option) + + // Container Settings + AppOptionMenuType.EditContainer, + AppOptionMenuType.ResetToDefaults, + AppOptionMenuType.ResetDrm, + AppOptionMenuType.UseKnownConfig, + -> containerSettings.add(option) + + // Cloud Saves + AppOptionMenuType.ForceCloudSync, + AppOptionMenuType.ForceDownloadRemote, + AppOptionMenuType.ForceUploadLocal, + -> cloudSaves.add(option) + + // Help & Info + AppOptionMenuType.StorePage, + AppOptionMenuType.GetSupport, + AppOptionMenuType.SubmitFeedback, + AppOptionMenuType.FetchSteamGridDBImages, + -> helpInfo.add(option) + } + } + + return linkedMapOf( + OptionCategory.QUICK_ACTIONS to quickActions, + OptionCategory.GAME_MANAGEMENT to gameManagement, + OptionCategory.CONTAINER to containerSettings, + OptionCategory.CLOUD_SAVES to cloudSaves, + OptionCategory.HELP_INFO to helpInfo, + ) +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt index b9f1410b7..cbe4c7314 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt @@ -1,77 +1,45 @@ package app.gamenative.ui.screen.library.components import android.content.res.Configuration -import app.gamenative.data.GameSource -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring 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.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Face4 -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect 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.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.gamenative.PrefManager -import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem -import app.gamenative.service.DownloadService -import app.gamenative.service.SteamService import app.gamenative.ui.enums.PaneType import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.util.ListItemImage -import app.gamenative.utils.CustomGameScanner -import java.io.File -import android.net.Uri +/** + * Library app item that displays a game in either list or grid view. + * + * This is the main entry point for displaying library items. It delegates to: + * - [ListViewCard] for list view (PaneType.LIST) + * - [GridViewCard] for grid views (PaneType.GRID_HERO, PaneType.GRID_CAPSULE) + */ @Composable internal fun AppItem( modifier: Modifier = Modifier, @@ -92,7 +60,6 @@ internal fun AppItem( alpha = 1f } - // Reset alpha and hideText when image URL changes (e.g., when new images are fetched) LaunchedEffect(imageRefreshCounter) { if (paneType != PaneType.LIST) { hideText = true @@ -100,462 +67,56 @@ internal fun AppItem( } } - // True when selected, e.g. with controller var isFocused by remember { mutableStateOf(false) } - // Border is used to highlight selected card - val border = if (isFocused) { - androidx.compose.foundation.BorderStroke( - width = 3.dp, - brush = Brush.verticalGradient( - colors = listOf( - MaterialTheme.colorScheme.tertiary.copy(alpha = 0.5f), - MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), - ) - ) - ) - } else { - androidx.compose.foundation.BorderStroke( - width = 1.dp, - MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), - ) + // More subtle scale for list view, slightly larger for grid views + val targetScale = when { + !isFocused -> 1f + paneType == PaneType.LIST -> 1.015f + else -> 1.03f } - // Modern card-style item with gradient hover effect - Card( - modifier = modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .onFocusChanged { focusState -> - isFocused = focusState.isFocused - if (isFocused) { - onFocus() - } - } - .clickable( - onClick = onClick, - interactionSource = remember { MutableInteractionSource() }, - indication = null - ), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + val scale by animateFloatAsState( + targetValue = targetScale, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, ), - border = border, - ) { - val outerPadding = if (paneType == PaneType.LIST) { - // Padding to make text easy to read - 16.dp - } else { - 0.dp - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(outerPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Game icon - Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)), - ) { - if (paneType == PaneType.LIST) { - val iconUrl = remember(appInfo.appId) { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - val path = CustomGameScanner.findIconFileForCustomGame(context, appInfo.appId) - if (!path.isNullOrEmpty()) { - if (path.startsWith("file://")) path else "file://$path" - } else { - appInfo.clientIconUrl - } - } else appInfo.clientIconUrl - } - ListItemImage( - modifier = Modifier.size(56.dp), - imageModifier = Modifier.clip(RoundedCornerShape(10.dp)), - image = { iconUrl } - ) - } else { - val aspectRatio = if (paneType == PaneType.GRID_CAPSULE) { 2/3f } else { 460/215f } - - // Helper function to find SteamGridDB images for Custom Games - fun findSteamGridDBImage(imageType: String): String? { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) - gameFolderPath?.let { path -> - val folder = java.io.File(path) - val imageFile = folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_$imageType") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - } - return imageFile?.let { android.net.Uri.fromFile(it).toString() } - } - } - return null - } - - val imageUrl = remember(appInfo.appId, paneType, imageRefreshCounter) { - if (appInfo.gameSource == GameSource.CUSTOM_GAME) { - // For Custom Games, use SteamGridDB images - when (paneType) { - PaneType.GRID_CAPSULE -> { - // Vertical grid for capsule - findSteamGridDBImage("grid_capsule") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" - } - PaneType.GRID_HERO -> { - // Horizontal grid for hero view - findSteamGridDBImage("grid_hero") - ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" - } - else -> { - // For list view, use heroes endpoint (not grid_hero) - val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) - val heroUrl = gameFolderPath?.let { path -> - val folder = java.io.File(path) - val heroFile = folder.listFiles()?.firstOrNull { file -> - file.name.startsWith("steamgriddb_hero") && - !file.name.contains("grid") && - (file.name.endsWith(".png", ignoreCase = true) || - file.name.endsWith(".jpg", ignoreCase = true) || - file.name.endsWith(".webp", ignoreCase = true)) - } - heroFile?.let { android.net.Uri.fromFile(it).toString() } - } - heroUrl ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" - } - } - } else { - // For Steam games, use standard Steam URLs - if (paneType == PaneType.GRID_CAPSULE) { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/library_600x900.jpg" - } else { - "https://shared.steamstatic.com/store_item_assets/steam/apps/" + appInfo.gameId + "/header.jpg" - } - } - } - - // Reset alpha and hideText when image URL changes (e.g., when new images are fetched) - LaunchedEffect(imageUrl) { - if (paneType != PaneType.LIST) { - hideText = true - alpha = 1f - } - } - - Box { - ListItemImage( - modifier = Modifier.aspectRatio(aspectRatio), - imageModifier = Modifier - .clip(RoundedCornerShape(3.dp)) - .alpha(alpha), - image = { imageUrl }, - onFailure = { - hideText = false - alpha = 0.1f - } - ) - - // Header overlay with compatibility status - compatibilityStatus?.let { status -> - val (text, color) = when (status) { - GameCompatibilityStatus.COMPATIBLE -> stringResource(R.string.library_compatible) to Color.Green - GameCompatibilityStatus.GPU_COMPATIBLE -> stringResource(R.string.library_compatible) to Color.Green - GameCompatibilityStatus.UNKNOWN -> stringResource(R.string.library_compatibility_unknown) to Color.Gray - GameCompatibilityStatus.NOT_COMPATIBLE -> stringResource(R.string.library_not_compatible) to Color.Red - } - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.6f)) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = color, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.align(Alignment.Center) - ) - } - } - } - - // Only display text if the image loading has failed - if (! hideText) { - GameInfoBlock( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(8.dp), - appInfo = appInfo, - isRefreshing = isRefreshing, - compatibilityStatus = compatibilityStatus, - ) - } else { - var isInstalled by remember(appInfo.appId, appInfo.gameSource) { - when (appInfo.gameSource) { - GameSource.STEAM -> mutableStateOf(SteamService.isAppInstalled(appInfo.gameId)) - GameSource.CUSTOM_GAME -> mutableStateOf(true) // Custom Games are always considered installed - else -> mutableStateOf(false) - } - } - // Update installation status when refresh completes - LaunchedEffect(isRefreshing) { - if (!isRefreshing) { - // Refresh just completed, check installation status - isInstalled = when (appInfo.gameSource) { - GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) - GameSource.CUSTOM_GAME -> true - else -> false - } - } - } - - // Calculate padding for text to prevent overlap with icons - val hasIcons = isInstalled || appInfo.isShared - val iconWidth = when { - isInstalled && appInfo.isShared -> 44.dp // Two icons + spacing - hasIcons -> 22.dp // One icon + spacing - else -> 0.dp - } - - // Black footer overlay with game title - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .background(Color.Black.copy(alpha = 0.6f)) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text( - text = appInfo.name, - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), - color = Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .align(Alignment.CenterStart) - .padding(end = iconWidth) - ) - - // Status icons for install status/family share - if (hasIcons) { - Row( - modifier = Modifier.align(alignment = Alignment.CenterEnd), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (isInstalled) { - Icon( - Icons.Filled.Check, - contentDescription = stringResource(R.string.library_installed), - tint = Color.White, - modifier = Modifier.size(16.dp) - ) - } - if (appInfo.isShared) { - Icon( - Icons.Filled.Face4, - contentDescription = stringResource(R.string.library_family_shared), - tint = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.size(16.dp) - ) - } - } - } - } - } - } - } - - if (paneType == PaneType.LIST) { - GameInfoBlock( - modifier = Modifier.weight(1f), - appInfo = appInfo, - isRefreshing = isRefreshing, - compatibilityStatus = compatibilityStatus, - ) - - // Play/Open button - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - shape = RoundedCornerShape(12.dp), - modifier = Modifier.height(40.dp) - ) { - Text( - text = stringResource(R.string.library_open), - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold - ) - ) - } - } - } - } -} - -@Composable -internal fun GameInfoBlock( - modifier: Modifier, - appInfo: LibraryItem, - isRefreshing: Boolean = false, - compatibilityStatus: GameCompatibilityStatus? = null, -) { - // For text displayed in list view, or as override if image loading fails - - // Determine download and install state for Steam games only - val isSteam = appInfo.gameSource == GameSource.STEAM - val downloadInfo = remember(appInfo.appId) { if (isSteam) SteamService.getAppDownloadInfo(appInfo.gameId) else null } - var downloadProgress by remember(downloadInfo) { mutableFloatStateOf(downloadInfo?.getProgress() ?: 0f) } - val isDownloading = downloadInfo != null && downloadProgress < 1f - var isInstalledSteam by remember(appInfo.appId) { mutableStateOf(if (isSteam) SteamService.isAppInstalled(appInfo.gameId) else false) } - - // Update installation status when refresh completes - LaunchedEffect(isRefreshing) { - if (!isRefreshing) { - if (isSteam) { - // Refresh just completed, check installation status - isInstalledSteam = SteamService.isAppInstalled(appInfo.gameId) - } - } - } - - // Function to refresh progress from downloadInfo - can be called from remember and LaunchedEffect - val refreshProgress: () -> Unit = { - downloadProgress = downloadInfo?.getProgress() ?: 0f - } - - // Refresh progress when list reloads (for downloading games) or when downloadInfo changes - LaunchedEffect(appInfo.appId, downloadInfo, isRefreshing) { - if (downloadInfo != null) { - refreshProgress() - } - } - - // Listen to real-time progress updates via listener - DisposableEffect(downloadInfo) { - val onDownloadProgress: (Float) -> Unit = { progress -> - downloadProgress = progress - } - downloadInfo?.addProgressListener(onDownloadProgress) - - onDispose { - downloadInfo?.removeProgressListener(onDownloadProgress) - } - } - - var appSizeOnDisk by remember { mutableStateOf("") } - - var hideText by remember { mutableStateOf(true) } - var alpha = remember(Int) {1f} - - LaunchedEffect(isSteam, isInstalledSteam) { - if (isSteam && isInstalledSteam) { - appSizeOnDisk = "..." - DownloadService.getSizeOnDiskDisplay(appInfo.gameId) { appSizeOnDisk = it } - } - } - - // Game info - Column( - modifier = modifier, - ) { - Text( - text = appInfo.name, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold - ), - color = MaterialTheme.colorScheme.onSurface + label = "focusScale", + ) + + when (paneType) { + PaneType.LIST -> ListViewCard( + modifier = modifier, + appInfo = appInfo, + onClick = onClick, + onFocus = onFocus, + isFocused = isFocused, + onFocusChanged = { isFocused = it }, + isRefreshing = isRefreshing, + compatibilityStatus = compatibilityStatus, + context = context, ) - Column( - modifier = Modifier.padding(top = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // Status indicator - val (statusText, statusColor) = if (isSteam) { - val text = when { - isDownloading -> stringResource(R.string.library_installing) - isInstalledSteam -> stringResource(R.string.library_installed) - else -> stringResource(R.string.library_not_installed) - } - val color = when { - isDownloading || isInstalledSteam -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - } - text to color - } else { - // Custom Games are considered ready (no Steam install tracking) - stringResource(R.string.library_status_ready) to MaterialTheme.colorScheme.tertiary - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Status dot - Box( - modifier = Modifier - .size(8.dp) - .background(color = statusColor, shape = CircleShape) - ) - // Status text - Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = statusColor - ) - // Download percentage when installing - if (isDownloading) { - Text( - text = "${(downloadProgress * 100).toInt()}%", - style = MaterialTheme.typography.bodyMedium, - color = statusColor - ) - } - } - // Game size on its own line for installed Steam games only - if (isSteam && isInstalledSteam) { - Text( - text = "$appSizeOnDisk", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Family share indicator on its own line if needed - if (appInfo.isShared) { - Text( - text = stringResource(R.string.library_family_shared), - style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), - color = MaterialTheme.colorScheme.tertiary - ) - } - - // Compatibility status indicator on its own line if needed - compatibilityStatus?.let { status -> - val (text, color) = when (status) { - GameCompatibilityStatus.COMPATIBLE -> stringResource(R.string.library_compatible) to Color.Green - GameCompatibilityStatus.GPU_COMPATIBLE -> stringResource(R.string.library_compatible) to Color.Green - GameCompatibilityStatus.UNKNOWN -> stringResource(R.string.library_compatibility_unknown) to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - GameCompatibilityStatus.NOT_COMPATIBLE -> stringResource(R.string.library_not_compatible) to Color.Red - } - Text( - text = text, - style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), - color = color - ) - } - } + else -> GridViewCard( + modifier = modifier, + appInfo = appInfo, + onClick = onClick, + onFocus = onFocus, + isFocused = isFocused, + onFocusChanged = { isFocused = it }, + scale = scale, + paneType = paneType, + imageRefreshCounter = imageRefreshCounter, + hideText = hideText, + imageAlpha = alpha, + onImageLoadFailed = { + hideText = false + alpha = 0.1f + }, + compatibilityStatus = compatibilityStatus, + context = context, + ) } } @@ -570,7 +131,7 @@ private fun Preview_AppItem() { PluviaTheme { Surface { LazyColumn( - modifier = Modifier.padding(16.dp) + modifier = Modifier.padding(16.dp), ) { items( items = List(5) { idx -> @@ -584,7 +145,6 @@ private fun Preview_AppItem() { ) }, itemContent = { - // Show different compatibility states in preview val status = when (it.index % 4) { 0 -> GameCompatibilityStatus.COMPATIBLE 1 -> GameCompatibilityStatus.GPU_COMPATIBLE @@ -594,7 +154,7 @@ private fun Preview_AppItem() { AppItem( appInfo = it, onClick = {}, - compatibilityStatus = status + compatibilityStatus = status, ) }, ) @@ -603,7 +163,7 @@ private fun Preview_AppItem() { } } -@Preview(device = "spec:width=1920px,height=1080px,dpi=440") // Odin2 Mini +@Preview(device = "spec:width=1920px,height=1080px,dpi=440") @Composable private fun Preview_AppItemGrid() { PrefManager.init(LocalContext.current) @@ -622,18 +182,12 @@ private fun Preview_AppItemGrid() { ) } - // Hero LazyVerticalGrid( columns = GridCells.Fixed(4), horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, - bottom = 72.dp - ), + contentPadding = PaddingValues(20.dp), ) { items(items = appInfoList, key = { it.index }) { item -> - // Show different compatibility states in preview val status = when (item.index % 4) { 0 -> GameCompatibilityStatus.COMPATIBLE 1 -> GameCompatibilityStatus.GPU_COMPATIBLE @@ -644,34 +198,7 @@ private fun Preview_AppItemGrid() { appInfo = item, onClick = { }, paneType = PaneType.GRID_HERO, - compatibilityStatus = status - ) - } - } - - // Capsule - LazyVerticalGrid( - columns = GridCells.Fixed(5), - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, - bottom = 72.dp - ), - ) { - items(items = appInfoList, key = { it.index }) { item -> - // Show different compatibility states in preview - val status = when (item.index % 4) { - 0 -> GameCompatibilityStatus.COMPATIBLE - 1 -> GameCompatibilityStatus.GPU_COMPATIBLE - 2 -> GameCompatibilityStatus.NOT_COMPATIBLE - else -> GameCompatibilityStatus.UNKNOWN - } - AppItem( - appInfo = item, - onClick = { }, - paneType = PaneType.GRID_CAPSULE, - compatibilityStatus = status + compatibilityStatus = status, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt deleted file mode 100644 index 0003ccb89..000000000 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryBottomSheet.kt +++ /dev/null @@ -1,185 +0,0 @@ -package app.gamenative.ui.screen.library.components - -import android.content.res.Configuration -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.PhotoAlbum -import androidx.compose.material.icons.filled.PhotoSizeSelectActual -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import app.gamenative.ui.icons.CustomGame -import app.gamenative.ui.icons.Steam -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import app.gamenative.R -import app.gamenative.ui.component.FlowFilterChip -import app.gamenative.ui.enums.AppFilter -import app.gamenative.ui.enums.PaneType -import app.gamenative.data.GameSource -import app.gamenative.ui.theme.PluviaTheme -import java.util.EnumSet - -@Composable -@OptIn(ExperimentalLayoutApi::class) -fun LibraryBottomSheet( - selectedFilters: EnumSet, - onFilterChanged: (AppFilter) -> Unit, - currentView: PaneType, - onViewChanged: (PaneType) -> Unit, - showSteam: Boolean, - showCustomGames: Boolean, - onSourceToggle: (app.gamenative.data.GameSource) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - ) { - Text(text = stringResource(R.string.library_app_type), style = MaterialTheme.typography.titleLarge) - Spacer(modifier = Modifier.height(8.dp)) - FlowRow( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - AppFilter.entries.forEach { appFilter -> - // TODO properly fix this (and the one below) - if (appFilter.code !in listOf(0x01, 0x20)) { - FlowFilterChip( - onClick = { onFilterChanged(appFilter) }, - label = { Text(text = appFilter.displayText) }, - selected = selectedFilters.contains(appFilter), - leadingIcon = { Icon(imageVector = appFilter.icon, contentDescription = null) }, - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text(text = stringResource(R.string.library_app_status), style = MaterialTheme.typography.titleLarge) - Spacer(modifier = Modifier.height(8.dp)) - FlowRow { - AppFilter.entries.forEach { appFilter -> - if (appFilter.code in listOf(0x01, 0x20)) { - FlowFilterChip( - onClick = { onFilterChanged(appFilter) }, - label = { Text(text = appFilter.displayText) }, - selected = selectedFilters.contains(appFilter), - leadingIcon = { Icon(imageVector = appFilter.icon, contentDescription = null) }, - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text(text = stringResource(R.string.library_layout), style = MaterialTheme.typography.titleLarge) - Spacer(modifier = Modifier.height(8.dp)) - FlowRow( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - FlowFilterChip( - onClick = { onSourceToggle(GameSource.STEAM) }, - label = { Text(text = stringResource(R.string.library_source_steam)) }, - selected = showSteam, - leadingIcon = { Icon(imageVector = Icons.Filled.Steam, contentDescription = null) }, - ) - FlowFilterChip( - onClick = { onSourceToggle(GameSource.CUSTOM_GAME) }, - label = { Text(text = stringResource(R.string.library_source_custom)) }, - selected = showCustomGames, - leadingIcon = { Icon(imageVector = Icons.Filled.CustomGame, contentDescription = null) }, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text(text = stringResource(R.string.library_layout_title), style = MaterialTheme.typography.titleLarge) - Spacer(modifier = Modifier.height(8.dp)) - FlowRow ( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - FlowFilterChip( - onClick = { onViewChanged(PaneType.LIST) }, - label = { Text(text = stringResource(R.string.library_layout_list)) }, - selected = (currentView == PaneType.LIST), - leadingIcon = { Icon(imageVector = Icons.AutoMirrored.Filled.List, contentDescription = null) }, - ) - FlowFilterChip( - onClick = { onViewChanged(PaneType.GRID_CAPSULE) }, - label = { Text(text = stringResource(R.string.library_layout_capsule)) }, - selected = (currentView == PaneType.GRID_CAPSULE), - leadingIcon = { Icon(imageVector = Icons.Default.PhotoAlbum, contentDescription = null) }, - ) - FlowFilterChip( - onClick = { onViewChanged(PaneType.GRID_HERO) }, - label = { Text(text = stringResource(R.string.library_layout_hero)) }, - selected = (currentView == PaneType.GRID_HERO), - leadingIcon = { Icon(imageVector = Icons.Default.PhotoSizeSelectActual, contentDescription = null) }, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) // A little extra padding. - } -} - -/*********** - * PREVIEW * - ***********/ - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -@Preview -@Composable -private fun Preview_LibraryBottomSheet() { - PluviaTheme { - Surface { - LibraryBottomSheet( - selectedFilters = EnumSet.of(AppFilter.GAME, AppFilter.DEMO), - onFilterChanged = { }, - currentView = PaneType.LIST, - onViewChanged = { }, - showSteam = true, - showCustomGames = true, - onSourceToggle = { }, - ) - } - } -} - -// Note: Previews seem to be broken for this, run it manually - -// @OptIn(ExperimentalMaterial3Api::class) -// @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) -// @Preview -// @Composable -// private fun Preview_LibraryBottomSheet_AsSheet() { -// PluviaTheme { -// Scaffold { paddingValues -> -// Box( -// modifier = Modifier -// .fillMaxSize() -// .padding(paddingValues), -// ) { -// Text(text = "Hello World") -// -// -// ModalBottomSheet( -// onDismissRequest = { }, -// content = { LibraryBottomSheet() }, -// ) -// } -// } -// } -// } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt index c29e51615..2be0e7348 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt @@ -1,38 +1,20 @@ package app.gamenative.ui.screen.library.components -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import app.gamenative.PrefManager -import app.gamenative.data.LibraryItem import app.gamenative.data.GameSource +import app.gamenative.data.LibraryItem import app.gamenative.ui.data.LibraryState import app.gamenative.ui.enums.AppFilter import app.gamenative.ui.screen.library.AppScreen import app.gamenative.ui.theme.PluviaTheme import java.util.EnumSet -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LibraryDetailPane( libraryItem: LibraryItem?, @@ -41,32 +23,21 @@ internal fun LibraryDetailPane( ) { Surface { if (libraryItem == null) { - // Simply use the regular LibraryListPane with empty data val listState = rememberLazyGridState() - val sheetState = rememberModalBottomSheetState() val emptyState = remember { LibraryState( appInfoList = emptyList(), - // Use the same default filter as in PrefManager (GAME) - appInfoSortType = EnumSet.of(AppFilter.GAME) + appInfoSortType = EnumSet.of(AppFilter.GAME), ) } LibraryListPane( state = emptyState, listState = listState, - sheetState = sheetState, - onFilterChanged = {}, + currentLayout = PrefManager.libraryLayout, onPageChange = {}, - onModalBottomSheet = {}, - onIsSearching = {}, - onLogout = {}, onNavigate = {}, - onSearchQuery = {}, - onNavigateRoute = {}, - onGoOnline = {}, onRefresh = {}, - onSourceToggle = {}, ) } else { AppScreen( @@ -93,7 +64,7 @@ private fun Preview_LibraryDetailPane() { appId = "${GameSource.STEAM.name}_${Int.MAX_VALUE}", name = "Preview Game", iconHash = "", - gameSource = GameSource.STEAM + gameSource = GameSource.STEAM, ), onClickPlay = { }, onBack = { }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt new file mode 100644 index 000000000..61e2b33a2 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt @@ -0,0 +1,354 @@ +package app.gamenative.ui.screen.library.components + +import android.content.Context +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Face4 +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.data.GameSource +import app.gamenative.data.LibraryItem +import app.gamenative.service.SteamService +import app.gamenative.ui.component.CompatibilityBadge +import app.gamenative.ui.enums.PaneType +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.ui.util.ListItemImage +import app.gamenative.utils.CustomGameScanner +import java.io.File + +/** + * Grid card for Hero/Capsule layout views. + */ +@Composable +internal fun GridViewCard( + modifier: Modifier, + appInfo: LibraryItem, + onClick: () -> Unit, + onFocus: () -> Unit, + isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, + scale: Float, + paneType: PaneType, + imageRefreshCounter: Long, + hideText: Boolean, + imageAlpha: Float, + onImageLoadFailed: () -> Unit, + compatibilityStatus: GameCompatibilityStatus?, + context: Context, +) { + val aspectRatio = if (paneType == PaneType.GRID_CAPSULE) 2f / 3f else 460f / 215f + val glowColor = MaterialTheme.colorScheme.primary + + Box( + modifier = modifier + .padding(vertical = 4.dp) + .scale(scale) + .then( + if (isFocused) { + Modifier.drawBehind { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + glowColor.copy(alpha = 0.3f), + Color.Transparent, + ), + radius = size.maxDimension * 0.7f, + ), + radius = size.maxDimension * 0.6f, + center = center, + ) + } + } else { + Modifier + }, + ), + ) { + val interactionSource = remember { MutableInteractionSource() } + val isItemFocused by interactionSource.collectIsFocusedAsState() + + LaunchedEffect(isItemFocused) { + onFocusChanged(isItemFocused) + if (isItemFocused) onFocus() + } + + Card( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .clickable( + onClick = onClick, + interactionSource = interactionSource, + indication = null, + ), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = Color.Transparent, + ), + border = if (isFocused) { + BorderStroke( + 2.dp, + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ) + } else { + null + }, + ) { + Box(modifier = Modifier.fillMaxSize()) { + // Game image + val imageUrl = remember(appInfo.appId, paneType, imageRefreshCounter) { + getGridImageUrl(context, appInfo, paneType) + } + + ListItemImage( + modifier = Modifier.fillMaxSize(), + imageModifier = Modifier + .fillMaxSize() + .alpha(imageAlpha), + image = { imageUrl }, + onFailure = { onImageLoadFailed() }, + ) + + // Gradient overlay at bottom for title + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(if (paneType == PaneType.GRID_CAPSULE) 80.dp else 56.dp) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.85f), + ), + ), + ), + ) + + // Title and status icons at bottom + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = appInfo.name, + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.SemiBold, + shadow = Shadow( + color = Color.Black, + offset = Offset(1f, 1f), + blurRadius = 2f, + ), + ), + color = Color.White, + maxLines = if (paneType == PaneType.GRID_CAPSULE) 2 else 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + GridStatusIcons(appInfo = appInfo) + } + + // Compatibility badge + compatibilityStatus?.let { status -> + if (status != GameCompatibilityStatus.UNKNOWN) { + CompatibilityBadge( + status = status, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) + } + } + + // Fallback text when image fails to load + if (!hideText) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = appInfo.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + } + } + } + } +} + +/** + * Status icons for grid view (installed, family share). + */ +@Composable +private fun GridStatusIcons(appInfo: LibraryItem) { + val isInstalled by remember(appInfo.appId, appInfo.gameSource) { + mutableStateOf( + when (appInfo.gameSource) { + GameSource.STEAM -> SteamService.isAppInstalled(appInfo.gameId) + GameSource.CUSTOM_GAME -> true + }, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isInstalled) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.library_installed), + tint = PluviaTheme.colors.statusInstalled, + modifier = Modifier.size(12.dp), + ) + } + } + if (appInfo.isShared) { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.Face4, + contentDescription = stringResource(R.string.library_family_shared), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(12.dp), + ) + } + } + } +} + +/** + * Gets the appropriate image URL for a game in grid view. + * TODO: this probably needs to be abstracted + */ +internal fun getGridImageUrl( + context: Context, + appInfo: LibraryItem, + paneType: PaneType, +): String { + fun findSteamGridDBImage(imageType: String): String? { + if (appInfo.gameSource == GameSource.CUSTOM_GAME) { + val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) + gameFolderPath?.let { path -> + val folder = File(path) + val imageFile = folder.listFiles()?.firstOrNull { file -> + file.name.startsWith("steamgriddb_$imageType") && + ( + file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true) + ) + } + return imageFile?.let { android.net.Uri.fromFile(it).toString() } + } + } + return null + } + + return if (appInfo.gameSource == GameSource.CUSTOM_GAME) { + when (paneType) { + PaneType.GRID_CAPSULE -> { + findSteamGridDBImage("grid_capsule") + ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/${appInfo.gameId}/library_600x900.jpg" + } + + PaneType.GRID_HERO -> { + findSteamGridDBImage("grid_hero") + ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/${appInfo.gameId}/header.jpg" + } + + else -> { + val gameFolderPath = CustomGameScanner.getFolderPathFromAppId(appInfo.appId) + val heroUrl = gameFolderPath?.let { path -> + val folder = File(path) + val heroFile = folder.listFiles()?.firstOrNull { file -> + file.name.startsWith("steamgriddb_hero") && + !file.name.contains("grid") && + ( + file.name.endsWith(".png", ignoreCase = true) || + file.name.endsWith(".jpg", ignoreCase = true) || + file.name.endsWith(".webp", ignoreCase = true) + ) + } + heroFile?.let { android.net.Uri.fromFile(it).toString() } + } + heroUrl + ?: "https://shared.steamstatic.com/store_item_assets/steam/apps/${appInfo.gameId}/header.jpg" + } + } + } else { + if (paneType == PaneType.GRID_CAPSULE) { + "https://shared.steamstatic.com/store_item_assets/steam/apps/${appInfo.gameId}/library_600x900.jpg" + } else { + "https://shared.steamstatic.com/store_item_assets/steam/apps/${appInfo.gameId}/header.jpg" + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt new file mode 100644 index 000000000..922ce7503 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt @@ -0,0 +1,265 @@ +package app.gamenative.ui.screen.library.components + +import android.content.Context +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face4 +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.data.GameSource +import app.gamenative.data.LibraryItem +import app.gamenative.service.SteamService +import app.gamenative.ui.component.CompatibilityBadge +import app.gamenative.ui.util.ListItemImage +import app.gamenative.utils.CustomGameScanner + +/** + * List view card with compact layout. + */ +@Composable +internal fun ListViewCard( + modifier: Modifier, + appInfo: LibraryItem, + onClick: () -> Unit, + onFocus: () -> Unit, + isFocused: Boolean, + onFocusChanged: (Boolean) -> Unit, + isRefreshing: Boolean, + compatibilityStatus: GameCompatibilityStatus?, + context: Context, +) { + val interactionSource = remember { MutableInteractionSource() } + val isItemFocused by interactionSource.collectIsFocusedAsState() + + LaunchedEffect(isItemFocused) { + onFocusChanged(isItemFocused) + if (isItemFocused) onFocus() + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable( + onClick = onClick, + interactionSource = interactionSource, + indication = null, + ), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors( + containerColor = if (isFocused) { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f) + }, + ), + border = if (isFocused) { + BorderStroke( + 2.dp, + Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + ), + ), + ) + } else { + null + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Game icon + val iconUrl = remember(appInfo.appId) { + getListIconUrl(context, appInfo) + } + + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + ListItemImage( + modifier = Modifier.fillMaxSize(), + imageModifier = Modifier.clip(RoundedCornerShape(10.dp)), + image = { iconUrl }, + ) + } + + // Game info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = appInfo.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Status row with compact badges + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + InstallStatusBadge(appInfo = appInfo, isRefreshing = isRefreshing) + + // Family share indicator + if (appInfo.isShared) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + Icons.Filled.Face4, + contentDescription = stringResource(R.string.library_family_shared), + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringResource(R.string.library_shared_short), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + } + + // Compatibility badge + compatibilityStatus?.let { status -> + CompatibilityBadge( + status = status, + showLabel = true, + ) + } + } + } +} + +/** + * Compact install status badge for list view. + */ +@Composable +private fun InstallStatusBadge( + appInfo: LibraryItem, + isRefreshing: Boolean, +) { + val isSteam = appInfo.gameSource == GameSource.STEAM + val downloadInfo = remember(appInfo.appId) { + if (isSteam) SteamService.getAppDownloadInfo(appInfo.gameId) else null + } + var downloadProgress by remember(downloadInfo) { + mutableFloatStateOf(downloadInfo?.getProgress() ?: 0f) + } + val isDownloading = downloadInfo != null && downloadProgress < 1f + var isInstalled by remember(appInfo.appId) { + mutableStateOf( + if (isSteam) { + SteamService.isAppInstalled(appInfo.gameId) + } else { + true // Custom Games always installed + }, + ) + } + + LaunchedEffect(isRefreshing) { + if (!isRefreshing && isSteam) { + isInstalled = SteamService.isAppInstalled(appInfo.gameId) + } + } + + DisposableEffect(downloadInfo) { + val onProgress: (Float) -> Unit = { downloadProgress = it } + downloadInfo?.addProgressListener(onProgress) + onDispose { downloadInfo?.removeProgressListener(onProgress) } + } + + val (text, color) = when { + !isSteam -> stringResource(R.string.library_status_ready) to MaterialTheme.colorScheme.tertiary + + isDownloading -> "${(downloadProgress * 100).toInt()}%" to MaterialTheme.colorScheme.primary + + isInstalled -> stringResource(R.string.library_installed) to MaterialTheme.colorScheme.tertiary + + else -> stringResource(R.string.library_not_installed) to MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(color, CircleShape), + ) + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + ) + } +} + +/** + * Gets the icon URL for a game in list view. + */ +private fun getListIconUrl(context: Context, appInfo: LibraryItem): String { + return if (appInfo.gameSource == GameSource.CUSTOM_GAME) { + val path = CustomGameScanner.findIconFileForCustomGame(context, appInfo.appId) + if (!path.isNullOrEmpty()) { + if (path.startsWith("file://")) path else "file://$path" + } else { + appInfo.clientIconUrl + } + } else { + appInfo.clientIconUrl + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index d9531e1bd..7caf535fa 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -1,188 +1,114 @@ package app.gamenative.ui.screen.library.components import android.content.res.Configuration -import androidx.compose.foundation.background +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween 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.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetState import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import app.gamenative.PrefManager +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem +import app.gamenative.ui.component.Scrollbar import app.gamenative.ui.data.LibraryState -import app.gamenative.ui.enums.AppFilter +import app.gamenative.ui.enums.PaneType import app.gamenative.ui.internal.fakeAppInfo -import app.gamenative.service.DownloadService -import app.gamenative.service.SteamService import app.gamenative.ui.theme.PluviaTheme -import app.gamenative.ui.component.topbar.AccountButton -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.input.pointer.pointerInteropFilter +import app.gamenative.ui.util.AdaptivePadding +import app.gamenative.ui.util.WindowWidthClass +import app.gamenative.ui.util.rememberWindowWidthClass import kotlinx.coroutines.delay -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.snapshotFlow -import app.gamenative.PrefManager -import app.gamenative.utils.DeviceUtils -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.distinctUntilChanged -import app.gamenative.data.GameSource -import app.gamenative.ui.enums.PaneType -import app.gamenative.ui.screen.PluviaScreen -import app.gamenative.utils.PaddingUtils -import timber.log.Timber - -/** - * Calculates the installed games count based on the current filter state. - * - * @param state The current library state containing filters and visibility settings - * @return The number of installed games, respecting current filters and source visibility - */ -private fun calculateInstalledCount(state: LibraryState): Int { - // If INSTALLED filter is active, all items in the filtered list are installed - if (state.appInfoSortType.contains(AppFilter.INSTALLED)) { - return state.totalAppsInFilter - } - - // Otherwise, count all installed games (respecting source visibility) - val downloadDirectoryApps = DownloadService.getDownloadDirectoryApps() - - // Count installed Steam games - val steamCount = if (state.showSteamInLibrary) { - downloadDirectoryApps.count() - } else { - 0 - } - - // Count Custom Games (always considered "installed") - val customGameCount = if (state.showCustomGamesInLibrary) { - PrefManager.customGamesCount - } else { - 0 - } - - return steamCount + customGameCount -} +import kotlinx.coroutines.flow.filterNotNull @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun LibraryListPane( state: LibraryState, listState: LazyGridState, - sheetState: SheetState, - onFilterChanged: (AppFilter) -> Unit, - onModalBottomSheet: (Boolean) -> Unit, + currentLayout: PaneType, onPageChange: (Int) -> Unit, - onIsSearching: (Boolean) -> Unit, - onLogout: () -> Unit, onNavigate: (String) -> Unit, - onSearchQuery: (String) -> Unit, - onNavigateRoute: (String) -> Unit, - onGoOnline: () -> Unit, onRefresh: () -> Unit, - onSourceToggle: (GameSource) -> Unit, - isOffline: Boolean = false, + modifier: Modifier = Modifier, ) { - val context = LocalContext.current val snackBarHost = remember { SnackbarHostState() } - - // Calculate installed count based on current filter state - val installedCount = remember( - state.appInfoSortType, - state.showSteamInLibrary, - state.showCustomGamesInLibrary, - state.totalAppsInFilter - ) { - calculateInstalledCount(state) - } - - val pullToRefreshState = rememberPullToRefreshState() + val windowWidthClass = rememberWindowWidthClass() + + val columnType = remember(currentLayout, windowWidthClass) { + when (currentLayout) { + PaneType.GRID_HERO -> { + val minSize = when (windowWidthClass) { + WindowWidthClass.COMPACT -> 160.dp + WindowWidthClass.MEDIUM -> 180.dp + WindowWidthClass.EXPANDED -> 200.dp + } + GridCells.Adaptive(minSize = minSize) + } + PaneType.GRID_CAPSULE -> { + val minSize = when (windowWidthClass) { + WindowWidthClass.COMPACT -> 110.dp + WindowWidthClass.MEDIUM -> 130.dp + WindowWidthClass.EXPANDED -> 150.dp + } + GridCells.Adaptive(minSize = minSize) + } - - - // Responsive width for better layouts - val isViewWide = DeviceUtils.isViewWide(currentWindowAdaptiveInfo()) - - var paneType: PaneType by remember { mutableStateOf(PrefManager.libraryLayout) } - val columnType = remember(paneType) { - when (paneType) { - PaneType.GRID_HERO -> GridCells.Adaptive(minSize = 200.dp) - PaneType.GRID_CAPSULE -> GridCells.Adaptive(minSize = 150.dp) else -> GridCells.Fixed(1) } } - // Infinite scroll: load next page when scrolled to bottom + val horizontalPadding = AdaptivePadding.horizontal() + val gridSpacing = AdaptivePadding.gridSpacing() + LaunchedEffect(listState, state.appInfoList.size) { snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } .filterNotNull() .distinctUntilChanged() .collect { lastVisibleIndex -> - if (lastVisibleIndex >= state.appInfoList.lastIndex - && state.appInfoList.size < state.totalAppsInFilter) { + if (lastVisibleIndex >= state.appInfoList.lastIndex && + state.appInfoList.size < state.totalAppsInFilter + ) { onPageChange(1) } } } - LaunchedEffect(isViewWide, paneType) { - // Set initial paneType at first launch depending on orientation - if (paneType == PaneType.UNDECIDED) { - // Default hero for landscape/tablets, or list for portrait phones - if (isViewWide) { - paneType = PaneType.GRID_HERO - } else { - paneType = PaneType.GRID_CAPSULE - } - PrefManager.libraryLayout = paneType - } - - } - var targetOfScroll by remember { mutableIntStateOf(-1) } LaunchedEffect(targetOfScroll) { if (targetOfScroll != -1) { @@ -190,183 +116,89 @@ internal fun LibraryListPane( } } - val headerTopPadding = PaddingUtils.statusBarAwarePadding().calculateTopPadding() - Scaffold( - snackbarHost = { SnackbarHost(snackBarHost) } + modifier = modifier, + snackbarHost = { SnackbarHost(snackBarHost) }, ) { paddingValues -> - Column( + Box( modifier = Modifier .fillMaxSize() - .padding(top = paddingValues.calculateTopPadding()) + .padding(paddingValues), ) { - // Modern Header with gradient - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = headerTopPadding) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween - ) { - Column { - Text( - text = "GameNative", - style = MaterialTheme.typography.headlineSmall.copy( - fontWeight = FontWeight.Bold, - brush = Brush.horizontalGradient( - colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary - ) - ) - ) - ) - Text( - text = androidx.compose.ui.res.stringResource( - app.gamenative.R.string.library_game_count, - state.totalAppsInFilter, - installedCount - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + var shouldShowSkeletonOverlay by remember { mutableStateOf(true) } - if (isViewWide) { - Box( - modifier = Modifier - .weight(1f) - .padding(horizontal = 30.dp) - ) { - LibrarySearchBar( - state = state, - listState = listState, - onSearchQuery = onSearchQuery, - ) - } - } + val skeletonAlpha by animateFloatAsState( + targetValue = if (shouldShowSkeletonOverlay) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "skeletonFadeOut", + ) - // User profile button - Box( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - .padding(8.dp) - ) { - AccountButton( - onNavigateRoute = onNavigateRoute, - onLogout = onLogout, - onGoOnline = onGoOnline, - isOffline = isOffline, - ) + LaunchedEffect(state.isLoading, state.appInfoList.size, state.totalAppsInFilter) { + shouldShowSkeletonOverlay = when { + state.totalAppsInFilter == 0 -> false + state.isLoading && state.appInfoList.isEmpty() -> true + state.appInfoList.isNotEmpty() && !state.isLoading -> { + delay(100) + false } + else -> false } } - if (! isViewWide) { - // Search bar - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 12.dp) - ) { - LibrarySearchBar( - state = state, - listState = listState, - onSearchQuery = onSearchQuery, - ) - } + val totalSkeletonCount = remember(state.showSteamInLibrary, state.showCustomGamesInLibrary) { + val customCount = if (state.showCustomGamesInLibrary) PrefManager.customGamesCount else 0 + val steamCount = if (state.showSteamInLibrary) PrefManager.steamGamesCount else 0 + val total = customCount + steamCount + if (total == 0) 6 else minOf(total, 20) } - // Game list - Box( - modifier = Modifier.fillMaxSize(), - ) { - // Track skeleton overlay alpha (fade out when games are loaded) - // Show skeleton overlay when loading OR when list is empty (initial state) - // But hide if final count is 0 (no games match filters) - var shouldShowSkeletonOverlay by remember { - mutableStateOf(true) // Start visible - } - - // Fade out skeleton overlay when games appear - val skeletonAlpha by animateFloatAsState( - targetValue = if (shouldShowSkeletonOverlay) 1f else 0f, - animationSpec = tween(durationMillis = 300), - label = "skeletonFadeOut" - ) - - // Update skeleton overlay visibility based on loading state and games - LaunchedEffect(state.isLoading, state.appInfoList.size, state.totalAppsInFilter) { - // Hide skeleton loaders if final count is 0 (no games match filters) - if (state.totalAppsInFilter == 0 && !state.isLoading) { - shouldShowSkeletonOverlay = false - } else if (state.isLoading && state.appInfoList.isEmpty() && state.totalAppsInFilter > 0) { - // Still loading and we expect games, show skeleton overlay - shouldShowSkeletonOverlay = true - } else if (state.appInfoList.isNotEmpty() && !state.isLoading) { - // Games are loaded and loading is complete, start fading out skeleton overlay - delay(100) // Small delay to let games render and fade in - shouldShowSkeletonOverlay = false - } else if (!state.isLoading && state.appInfoList.isEmpty() && state.totalAppsInFilter == 0) { - // Loading complete but no games (filters exclude everything), hide skeletons - shouldShowSkeletonOverlay = false - } - } - - val totalSkeletonCount = remember(state.showSteamInLibrary, state.showCustomGamesInLibrary) { - val customCount = if (state.showCustomGamesInLibrary) PrefManager.customGamesCount else 0 - val steamCount = if (state.showSteamInLibrary) PrefManager.steamGamesCount else 0 - val total = customCount + steamCount - Timber.tag("LibraryListPane").d("Skeleton calculation - Custom: $customCount, Steam: $steamCount, Total: $total") - // Show at least a few skeletons, but not more than a reasonable amount - if (total == 0) 6 else minOf(total, 20) - } - - // Show actual games (base layer) - if (state.appInfoList.isNotEmpty()) { + if (state.appInfoList.isNotEmpty()) { + Scrollbar( + listState = listState, + modifier = Modifier.fillMaxSize(), + ) { PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = onRefresh, - state = pullToRefreshState + state = pullToRefreshState, + modifier = Modifier.fillMaxSize(), ) { LazyVerticalGrid( columns = columnType, state = listState, modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(gridSpacing), contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, - bottom = 72.dp + top = 80.dp, + start = horizontalPadding, + end = horizontalPadding + 4.dp, + bottom = 72.dp, ), ) { items(items = state.appInfoList, key = { it.index }) { item -> - // Fade-in animation for items var isVisible by remember(item.index) { mutableStateOf(false) } val alpha by animateFloatAsState( targetValue = if (isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 300), - label = "fadeIn" + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "fadeIn", ) LaunchedEffect(item.index) { + delay((item.index % 8) * 30L) isVisible = true } Box(modifier = Modifier.alpha(alpha)) { - if (item.index > 0 && paneType == PaneType.LIST) { - // Dividers in list view + if (item.index > 0 && currentLayout == PaneType.LIST) { HorizontalDivider() } AppItem( appInfo = item, onClick = { onNavigate(item.appId) }, - paneType = paneType, + paneType = currentLayout, onFocus = { targetOfScroll = item.index }, imageRefreshCounter = state.imageRefreshCounter, compatibilityStatus = state.compatibilityMap[item.name], @@ -379,7 +211,7 @@ internal fun LibraryListPane( modifier = Modifier .fillMaxWidth() .padding(16.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } @@ -388,60 +220,38 @@ internal fun LibraryListPane( } } } + } - // Skeleton loaders as overlay (fades out when games are loaded) - // Use a separate non-interactive state so it doesn't interfere with scrolling - val skeletonListState = remember { LazyGridState() } - if (skeletonAlpha > 0f) { - Box( - modifier = Modifier - .fillMaxSize() - .alpha(skeletonAlpha) - .pointerInteropFilter { false } // Non-interactive - allows touch events to pass through + val skeletonListState = remember { LazyGridState() } + if (skeletonAlpha > 0f) { + Box( + modifier = Modifier + .fillMaxSize() + .alpha(skeletonAlpha) + .pointerInteropFilter { false }, + ) { + LazyVerticalGrid( + columns = columnType, + state = skeletonListState, + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(gridSpacing), + contentPadding = PaddingValues( + top = 80.dp, + start = horizontalPadding, + end = horizontalPadding, + bottom = 72.dp, + ), ) { - LazyVerticalGrid( - columns = columnType, - state = skeletonListState, - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues( - start = 20.dp, - end = 20.dp, - bottom = 72.dp - ), - ) { - items(totalSkeletonCount) { index -> - if (index > 0 && paneType == PaneType.LIST) { - HorizontalDivider() - } - GameSkeletonLoader( - paneType = paneType, - ) + items(totalSkeletonCount) { index -> + if (index > 0 && currentLayout == PaneType.LIST) { + HorizontalDivider() } + GameSkeletonLoader( + paneType = currentLayout, + ) } } } - - if (state.modalBottomSheet) { - ModalBottomSheet( - onDismissRequest = { onModalBottomSheet(false) }, - sheetState = sheetState, - content = { - LibraryBottomSheet( - selectedFilters = state.appInfoSortType, - onFilterChanged = onFilterChanged, - currentView = paneType, - onViewChanged = { newPaneType -> - PrefManager.libraryLayout = newPaneType - paneType = newPaneType - }, - showSteam = state.showSteamInLibrary, - showCustomGames = state.showCustomGamesInLibrary, - onSourceToggle = onSourceToggle, - ) - }, - ) - } } } } @@ -451,28 +261,24 @@ internal fun LibraryListPane( * PREVIEW * ***********/ -@OptIn(ExperimentalMaterial3Api::class) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Preview(device = "spec:width=1920px,height=1080px,dpi=440") // Odin2 Mini @Composable private fun Preview_LibraryListPane() { val context = LocalContext.current PrefManager.init(context) - val sheetState = rememberModalBottomSheetState() - var state by remember { - mutableStateOf( - LibraryState( - appInfoList = List(15) { idx -> - val item = fakeAppInfo(idx) - LibraryItem( - index = idx, - appId = "${GameSource.STEAM.name}_${item.id}", - name = item.name, - iconHash = item.iconHash, - isShared = idx % 2 == 0, - ) - }, - ), + val state = remember { + LibraryState( + appInfoList = List(15) { idx -> + val item = fakeAppInfo(idx) + LibraryItem( + index = idx, + appId = "${GameSource.STEAM.name}_${item.id}", + name = item.name, + iconHash = item.iconHash, + isShared = idx % 2 == 0, + ) + }, ) } PluviaTheme { @@ -480,22 +286,10 @@ private fun Preview_LibraryListPane() { LibraryListPane( listState = LazyGridState(2), state = state, - sheetState = sheetState, - onFilterChanged = { }, + currentLayout = PaneType.GRID_HERO, onPageChange = { }, - onModalBottomSheet = { - val currentState = state.modalBottomSheet - println("State: $currentState") - state = state.copy(modalBottomSheet = !currentState) - }, - onIsSearching = { }, - onSearchQuery = { }, - onNavigateRoute = { }, - onLogout = { }, onNavigate = { }, - onGoOnline = { }, onRefresh = { }, - onSourceToggle = { }, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt new file mode 100644 index 000000000..4cf8d035c --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryOptionsPanel.kt @@ -0,0 +1,340 @@ +package app.gamenative.ui.screen.library.components + +import android.content.res.Configuration +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Compress +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.PhotoAlbum +import androidx.compose.material.icons.filled.PhotoSizeSelectActual +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.SortByAlpha +import androidx.compose.material.icons.filled.Storage +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.ui.component.OptionListItem +import app.gamenative.ui.component.OptionRadioItem +import app.gamenative.ui.component.OptionSectionHeader +import app.gamenative.ui.enums.AppFilter +import app.gamenative.ui.enums.PaneType +import app.gamenative.ui.enums.SortOption +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.ui.util.adaptivePanelWidth +import java.util.EnumSet + +@Composable +fun LibraryOptionsPanel( + isOpen: Boolean, + onDismiss: () -> Unit, + selectedFilters: EnumSet, + onFilterChanged: (AppFilter) -> Unit, + currentSortOption: SortOption, + onSortOptionChanged: (SortOption) -> Unit, + currentView: PaneType, + onViewChanged: (PaneType) -> Unit, + modifier: Modifier = Modifier, +) { + val firstItemFocusRequester = remember { FocusRequester() } + + BackHandler(enabled = isOpen) { + onDismiss() + } + + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = isOpen, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(150)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss + ) + ) + } + + AnimatedVisibility( + visible = isOpen, + enter = slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ), + exit = slideOutHorizontally( + targetOffsetX = { fullWidth -> -fullWidth }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + ) { + Surface( + modifier = Modifier + .width(adaptivePanelWidth(300.dp)) + .fillMaxHeight(), + shape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + shadowElevation = 24.dp, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, top = 16.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.options_panel_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSurface + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.options_panel_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = 12.dp) + ) { + OptionSectionHeader(text = stringResource(R.string.options_sort_by)) + Column( + modifier = Modifier + .fillMaxWidth() + .focusGroup() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + SortOption.entries.forEachIndexed { index, option -> + OptionRadioItem( + text = stringResource(option.displayTextRes), + selected = currentSortOption == option, + onClick = { onSortOptionChanged(option) }, + icon = option.icon(), + focusRequester = if (index == 0) firstItemFocusRequester else remember { FocusRequester() }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + OptionSectionHeader(text = stringResource(R.string.library_app_type)) + Column( + modifier = Modifier + .fillMaxWidth() + .focusGroup() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + AppFilter.entries.forEach { appFilter -> + if (appFilter.code !in listOf(0x01, 0x20)) { + OptionListItem( + text = appFilter.displayText, + selected = selectedFilters.contains(appFilter), + onClick = { onFilterChanged(appFilter) }, + icon = appFilter.icon, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + OptionSectionHeader(text = stringResource(R.string.library_app_status)) + Column( + modifier = Modifier + .fillMaxWidth() + .focusGroup() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + AppFilter.entries.forEach { appFilter -> + if (appFilter.code in listOf(0x01, 0x20)) { + OptionListItem( + text = appFilter.displayText, + selected = selectedFilters.contains(appFilter), + onClick = { onFilterChanged(appFilter) }, + icon = appFilter.icon, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + OptionSectionHeader(text = stringResource(R.string.library_layout_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .focusGroup() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + OptionRadioItem( + text = stringResource(R.string.library_layout_list), + selected = currentView == PaneType.LIST, + onClick = { onViewChanged(PaneType.LIST) }, + icon = Icons.AutoMirrored.Filled.List, + modifier = Modifier.fillMaxWidth() + ) + OptionRadioItem( + text = stringResource(R.string.library_layout_capsule), + selected = currentView == PaneType.GRID_CAPSULE, + onClick = { onViewChanged(PaneType.GRID_CAPSULE) }, + icon = Icons.Default.PhotoAlbum, + modifier = Modifier.fillMaxWidth() + ) + OptionRadioItem( + text = stringResource(R.string.library_layout_hero), + selected = currentView == PaneType.GRID_HERO, + onClick = { onViewChanged(PaneType.GRID_HERO) }, + icon = Icons.Default.PhotoSizeSelectActual, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + } + } + + LaunchedEffect(isOpen) { + if (isOpen) { + try { + firstItemFocusRequester.requestFocus() + } catch (_: Exception) { + // Focus request may fail if composition is not ready + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + device = "spec:width=1920px,height=1080px,dpi=440,orientation=landscape" +) +@Composable +private fun Preview_LibraryOptionsPanel() { + val context = LocalContext.current + PrefManager.init(context) + PluviaTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "Game Library", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + + LibraryOptionsPanel( + isOpen = true, + onDismiss = { }, + selectedFilters = EnumSet.of(AppFilter.GAME), + onFilterChanged = { }, + currentSortOption = SortOption.INSTALLED_FIRST, + onSortOptionChanged = { }, + currentView = PaneType.GRID_HERO, + onViewChanged = { }, + ) + } + } + } +} + +private fun SortOption.icon(): ImageVector = when (this) { + SortOption.INSTALLED_FIRST -> Icons.Default.Download + SortOption.NAME_ASC -> Icons.Default.SortByAlpha + SortOption.NAME_DESC -> Icons.Default.SortByAlpha + SortOption.RECENTLY_PLAYED -> Icons.Default.Schedule + SortOption.SIZE_SMALLEST -> Icons.Default.Compress + SortOption.SIZE_LARGEST -> Icons.Default.Storage +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt index f58678b55..3495a9b3e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibrarySearchBar.kt @@ -1,138 +1,321 @@ package app.gamenative.ui.screen.library.components +import android.graphics.drawable.ColorDrawable +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource +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.size import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.widget.doAfterTextChanged import app.gamenative.PrefManager -import app.gamenative.data.LibraryItem -import app.gamenative.data.GameSource +import app.gamenative.R import app.gamenative.ui.data.LibraryState -import app.gamenative.ui.internal.fakeAppInfo import app.gamenative.ui.theme.PluviaTheme -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @Composable -internal fun LibrarySearchBar( - state: LibraryState, +fun LibrarySearchBar( + isVisible: Boolean, + searchQuery: String, + resultCount: Int, listState: LazyGridState, onSearchQuery: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { - val keyboardController = LocalSoftwareKeyboardController.current - val internalSearchText = remember { MutableStateFlow(state.searchQuery) } - - val scope = rememberCoroutineScope() + AnimatedVisibility( + visible = isVisible, + enter = expandVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + expandFrom = Alignment.Top, + ) + fadeIn(), + exit = shrinkVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessHigh, + ), + shrinkTowards = Alignment.Top, + ) + fadeOut(), + modifier = modifier, + ) { + // Gradient background container + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.98f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + Color.Transparent, + ), + ), + ) + .padding(top = 8.dp, bottom = 20.dp, start = 12.dp, end = 12.dp), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SearchBarInput( + searchQuery = searchQuery, + listState = listState, + onSearchQuery = onSearchQuery, + onDismiss = onDismiss, + ) - // Lambda function to provide new test to both onSearchQuery and internalSearchText - val onSearchText: (String) -> Unit = { - onSearchQuery(it) - if (internalSearchText.value != it) { - // Input text changed, so update and scroll to top - internalSearchText.value = it - scope.launch { - listState.scrollToItem(0) + // Results count + if (searchQuery.isNotEmpty()) { + Text( + text = if (resultCount == 1) { + stringResource(R.string.search_results_one, resultCount) + } else { + stringResource(R.string.search_results_many, resultCount) + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + } } } } +} - // Prevent focus by default, so it doesn't scoop up every controller input for focus - val allowFocusing = remember { mutableStateOf(false) } +@Composable +private fun SearchBarInput( + searchQuery: String, + listState: LazyGridState, + onSearchQuery: (String) -> Unit, + onDismiss: () -> Unit, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + var editTextRef by remember { mutableStateOf(null) } + var isFocused by remember { mutableStateOf(false) } - // Modern search field with rounded corners - Box( + // Request focus when search bar appears + LaunchedEffect(editTextRef) { + editTextRef?.requestFocus() + } + + val onSearchText: (String) -> Unit = { newText -> + onSearchQuery(newText) + scope.launch { + listState.scrollToItem(0) + } + } + + Row( modifier = Modifier .fillMaxWidth() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - // When tapped, allow and request focus - allowFocusing.value = true - }, - contentAlignment = Alignment.CenterStart + .clip(RoundedCornerShape(24.dp)) + .background( + brush = Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ), + ), + ) + .then( + if (isFocused) { + Modifier.border( + 2.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + RoundedCornerShape(24.dp), + ) + } else { + Modifier + }, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - TextField( - value = state.searchQuery, - onValueChange = onSearchText, + // Back/Close button + Box( modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clip(RoundedCornerShape(16.dp)) - .focusable(allowFocusing.value), - placeholder = { - Text( - text = androidx.compose.ui.res.stringResource(app.gamenative.R.string.library_search_placeholder), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = androidx.compose.ui.res.stringResource(app.gamenative.R.string.library_search_description), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = "Close search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + + // Search icon + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant }, - trailingIcon = { - if (state.searchQuery.isNotEmpty()) { - IconButton( - onClick = { onSearchText("") }, - content = { - Icon( - Icons.Default.Clear, - contentDescription = androidx.compose.ui.res.stringResource(app.gamenative.R.string.library_search_clear), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + modifier = Modifier.size(22.dp), + ) + + // Text input using AndroidView with EditText + // This allows setting IME_FLAG_NO_EXTRACT_UI to prevent fullscreen keyboard in landscape + // TODO: there must be a better way of doing this + val textColor = MaterialTheme.colorScheme.onSurface + val hintColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + val cursorColor = MaterialTheme.colorScheme.primary + val placeholderText = stringResource(R.string.library_search_placeholder) + + AndroidView( + factory = { context -> + EditText(context).apply { + // Prevent fullscreen keyboard (extract mode) in landscape + imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or EditorInfo.IME_ACTION_SEARCH + inputType = android.text.InputType.TYPE_CLASS_TEXT + isSingleLine = true + hint = placeholderText + background = ColorDrawable(android.graphics.Color.TRANSPARENT) + setPadding(0, 0, 0, 0) + + // Set colors + setTextColor(textColor.toArgb()) + setHintTextColor(hintColor.toArgb()) + + // Text size matching bodyLarge + textSize = 16f + + // Handle search action + setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + keyboardController?.hide() + true + } else { + false } - ) + } + + // Handle D-pad navigation + setOnKeyListener { v, keyCode, event -> + if (event.action == KeyEvent.ACTION_DOWN && + keyCode == KeyEvent.KEYCODE_DPAD_DOWN + ) { + keyboardController?.hide() + // Use native focus search to find next focusable view below + val nextFocus = v.focusSearch(View.FOCUS_DOWN) + nextFocus?.requestFocus() + true + } else { + false + } + } + + // Text change listener + doAfterTextChanged { editable -> + onSearchText(editable?.toString() ?: "") + } + + // Focus change listener for border highlight + setOnFocusChangeListener { _, hasFocus -> + isFocused = hasFocus + } + + // Store reference for focus management + editTextRef = this } }, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }) + update = { editText -> + // Only update if text differs + if (editText.text.toString() != searchQuery) { + editText.setText(searchQuery) + editText.setSelection(searchQuery.length) + } + + // Update colors in case theme changes + editText.setTextColor(textColor.toArgb()) + editText.setHintTextColor(hintColor.toArgb()) + }, + modifier = Modifier + .weight(1f) + .height(24.dp), ) - } - // The dropdown search results are handled elsewhere in the LibraryList component + // Clear button + if (searchQuery.isNotEmpty()) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { onSearchText("") }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.library_search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } } /*********** @@ -147,20 +330,31 @@ private fun Preview_LibrarySearchBar() { PluviaTheme { Surface { LibrarySearchBar( - state = LibraryState( - isSearching = false, - appInfoList = List(5) { idx -> - val item = fakeAppInfo(idx) - LibraryItem( - index = idx, - appId = "${GameSource.STEAM.name}_${item.id}", - name = item.name, - iconHash = item.iconHash, - ) - }, - ), + isVisible = true, + searchQuery = "Balatro", + resultCount = 5, + listState = rememberLazyGridState(), + onSearchQuery = { }, + onDismiss = { }, + ) + } + } +} + +@Preview(uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES or android.content.res.Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_LibrarySearchBar_Empty() { + val context = LocalContext.current + PrefManager.init(context) + PluviaTheme { + Surface { + LibrarySearchBar( + isVisible = true, + searchQuery = "", + resultCount = 0, listState = rememberLazyGridState(), onSearchQuery = { }, + onDismiss = { }, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt new file mode 100644 index 000000000..323e7739a --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt @@ -0,0 +1,650 @@ +package app.gamenative.ui.screen.library.components + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import app.gamenative.R +import app.gamenative.ui.enums.LibraryTab +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.ui.util.WindowWidthClass +import app.gamenative.ui.util.rememberWindowWidthClass + +/** + * Tab bar for library navigation with sliding pill indicator. + * Adapts to screen width + */ +@Composable +fun LibraryTabBar( + currentTab: LibraryTab, + onTabSelected: (LibraryTab) -> Unit, + onPreviousTab: () -> Unit, + onNextTab: () -> Unit, + onOptionsClick: () -> Unit, + onSearchClick: () -> Unit, + onAddGameClick: () -> Unit, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val widthClass = rememberWindowWidthClass() + + when (widthClass) { + WindowWidthClass.COMPACT -> CompactLibraryTabBar( + currentTab = currentTab, + onTabSelected = onTabSelected, + onOptionsClick = onOptionsClick, + onSearchClick = onSearchClick, + onAddGameClick = onAddGameClick, + onMenuClick = onMenuClick, + modifier = modifier, + ) + + else -> ExpandedLibraryTabBar( + currentTab = currentTab, + onTabSelected = onTabSelected, + onPreviousTab = onPreviousTab, + onNextTab = onNextTab, + onOptionsClick = onOptionsClick, + onMenuClick = onMenuClick, + modifier = modifier, + ) + } +} + +/** + * Compact tab bar for narrow screens. + * Centered tabs with action buttons for Options, Search, Add Game, and Menu. + */ +@Composable +private fun CompactLibraryTabBar( + currentTab: LibraryTab, + onTabSelected: (LibraryTab) -> Unit, + onOptionsClick: () -> Unit, + onSearchClick: () -> Unit, + onAddGameClick: () -> Unit, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val tabs = LibraryTab.entries + + Box( + modifier = modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + Color.Transparent, + ), + ), + ) + .padding(top = 8.dp, bottom = 12.dp, start = 8.dp, end = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Left: Options button + CompactIconButton( + icon = Icons.Default.Tune, + contentDescription = stringResource(R.string.options), + onClick = onOptionsClick, + ) + + // Center: Tabs (takes remaining space, centered) + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tab -> + val isSelected = tab == currentTab + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + }, + ) + .selectable( + selected = isSelected, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { onTabSelected(tab) }, + ) + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(tab.labelResId), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + }, + ) + } + } + } + + // Right: Search, Add, Menu buttons + CompactIconButton( + icon = Icons.Default.Search, + contentDescription = stringResource(R.string.search), + onClick = onSearchClick, + ) + CompactIconButton( + icon = Icons.Default.Add, + contentDescription = stringResource(R.string.action_add_game), + onClick = onAddGameClick, + ) + CompactIconButton( + icon = Icons.Default.Menu, + contentDescription = stringResource(R.string.menu), + onClick = onMenuClick, + ) + } + } +} + +/** + * Simple icon button for compact tab bar. + */ +@Composable +private fun CompactIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .selectable( + selected = false, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + modifier = Modifier.size(20.dp), + ) + } +} + +/** + * Expanded tab bar for wide screens (landscape phone, tablet). + */ +@Composable +private fun ExpandedLibraryTabBar( + currentTab: LibraryTab, + onTabSelected: (LibraryTab) -> Unit, + onPreviousTab: () -> Unit, + onNextTab: () -> Unit, + onOptionsClick: () -> Unit, + onMenuClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val tabs = LibraryTab.entries + val currentIndex = tabs.indexOf(currentTab) + + // Track tab positions and widths for the sliding indicator + val tabPositions = remember { mutableStateMapOf() } + val tabWidths = remember { mutableStateMapOf() } + + val density = LocalDensity.current + + // Animated indicator position and width + val indicatorOffset by animateDpAsState( + targetValue = with(density) { (tabPositions[currentIndex] ?: 0f).toDp() }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "indicatorOffset", + ) + + val indicatorWidth by animateDpAsState( + targetValue = with(density) { (tabWidths[currentIndex] ?: 80f).toDp() }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "indicatorWidth", + ) + + // Gradient background container - content scrolls behind this + Box( + modifier = modifier + .background( + brush = Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.98f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + Color.Transparent, + ), + ), + ) + .padding(top = 8.dp, bottom = 20.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Options button (opens filter/sort panel) + IconActionButton( + icon = Icons.Default.Tune, + contentDescription = stringResource(R.string.options), + onClick = onOptionsClick, + ) + + // L1 button (previous tab) + ShoulderButton( + label = "L1", + onClick = onPreviousTab, + isLeft = true, + ) + + // Tab container with sliding indicator + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(24.dp)) + .background( + brush = Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ), + ) + .padding(4.dp), + contentAlignment = Alignment.CenterStart, + ) { + // Sliding pill indicator (rendered behind tabs) + Box( + modifier = Modifier + .offset { IntOffset(indicatorOffset.roundToPx(), 0) } + .width(indicatorWidth) + .height(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background( + brush = Brush.horizontalGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + ), + ), + ), + ) + + // Tab items row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEachIndexed { index, tab -> + TabItem( + tab = tab, + isSelected = tab == currentTab, + onClick = { onTabSelected(tab) }, + onPositioned = { position, width -> + tabPositions[index] = position + tabWidths[index] = width + }, + ) + } + } + } + + // R1 button (next tab) + ShoulderButton( + label = "R1", + onClick = onNextTab, + isLeft = false, + ) + + // Menu button (opens system menu) + IconActionButton( + icon = Icons.Default.Menu, + contentDescription = stringResource(R.string.menu), + onClick = onMenuClick, + ) + } + } +} + +@Composable +private fun ShoulderButton( + label: String, + onClick: () -> Unit, + isLeft: Boolean, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.15f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "shoulderButtonScale", + ) + + val alpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0.7f, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "shoulderButtonAlpha", + ) + + Box( + modifier = modifier + .scale(scale) + .size(44.dp) + .clip(CircleShape) + .background( + brush = Brush.radialGradient( + colors = if (isFocused) { + listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + ) + } else { + listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), + ) + }, + ), + ) + .then( + if (isFocused) { + Modifier.border( + 2.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + CircleShape, + ) + } else { + Modifier + }, + ) + .focusProperties { canFocus = false } + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .alpha(alpha), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isLeft) { + Icons.AutoMirrored.Filled.KeyboardArrowLeft + } else { + Icons.AutoMirrored.Filled.KeyboardArrowRight + }, + contentDescription = label, + tint = if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + }, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun IconActionButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.15f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "iconButtonScale", + ) + + val alpha by animateFloatAsState( + targetValue = if (isFocused) 1f else 0.7f, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "iconButtonAlpha", + ) + + Box( + modifier = modifier + .scale(scale) + .size(44.dp) + .clip(CircleShape) + .background( + brush = Brush.radialGradient( + colors = if (isFocused) { + listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + ) + } else { + listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), + ) + }, + ), + ) + .then( + if (isFocused) { + Modifier.border( + 2.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + CircleShape, + ) + } else { + Modifier + }, + ) + .focusProperties { canFocus = false } + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .alpha(alpha), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + }, + modifier = Modifier.size(22.dp), + ) + } +} + +@Composable +private fun TabItem( + tab: LibraryTab, + isSelected: Boolean, + onClick: () -> Unit, + onPositioned: (Float, Float) -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val textAlpha by animateFloatAsState( + targetValue = when { + isSelected -> 1f + isFocused -> 0.9f + else -> 0.6f + }, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "textAlpha", + ) + + Box( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .onGloballyPositioned { coordinates -> + onPositioned( + coordinates.positionInParent().x, + coordinates.size.width.toFloat(), + ) + } + .focusProperties { canFocus = false } + .selectable( + selected = isSelected, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 20.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(tab.labelResId), + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = when { + isSelected -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha) + }, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF1A1A1A) +@Composable +private fun Preview_LibraryTabBar() { + PluviaTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + LibraryTabBar( + currentTab = LibraryTab.ALL, + onTabSelected = {}, + onPreviousTab = {}, + onNextTab = {}, + onOptionsClick = {}, + onSearchClick = {}, + onAddGameClick = {}, + onMenuClick = {}, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF1A1A1A) +@Composable +private fun Preview_LibraryTabBar_Steam() { + PluviaTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + LibraryTabBar( + currentTab = LibraryTab.STEAM, + onTabSelected = {}, + onPreviousTab = {}, + onNextTab = {}, + onOptionsClick = {}, + onSearchClick = {}, + onAddGameClick = {}, + onMenuClick = {}, + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt new file mode 100644 index 000000000..078cf7873 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt @@ -0,0 +1,680 @@ +package app.gamenative.ui.screen.library.components + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.AirplaneTicket +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.StarHalf +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.gamenative.PluviaApp +import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.data.SteamFriend +import app.gamenative.events.SteamEvent +import app.gamenative.service.SteamService +import app.gamenative.ui.component.dialog.SupportersDialog +import app.gamenative.ui.screen.PluviaScreen +import app.gamenative.ui.theme.PluviaTheme +import app.gamenative.ui.util.SteamIconImage +import app.gamenative.ui.util.adaptivePanelWidth +import app.gamenative.ui.util.shouldShowGamepadUI +import app.gamenative.utils.getAvatarURL +import `in`.dragonbra.javasteam.enums.EPersonaState +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * A single menu item in the System Menu + */ +@Composable +private fun SystemMenuItem( + text: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, + isDestructive: Boolean = false, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.02f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "menuItemScale", + ) + + val backgroundColor = when { + isFocused -> MaterialTheme.colorScheme.primaryContainer + else -> Color.Transparent + } + + val contentColor = when { + isDestructive && isFocused -> MaterialTheme.colorScheme.error + isDestructive -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) + isFocused -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + + Box( + modifier = modifier + .scale(scale) + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor) + .focusRequester(focusRequester) + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(28.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = contentColor, + fontWeight = if (isFocused) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } +} + +/** + * Status option item for the dropdown + */ +@Composable +private fun StatusOption( + text: String, + statusColor: Color, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.02f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "statusOptionScale", + ) + + val backgroundColor = when { + isFocused -> MaterialTheme.colorScheme.primaryContainer + isSelected -> MaterialTheme.colorScheme.surfaceContainerHighest + else -> Color.Transparent + } + + Row( + modifier = modifier + .fillMaxWidth() + .scale(scale) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(statusColor, CircleShape), + ) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = if (isFocused) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Text( + text = "✓", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } +} + +/** + * Full-screen System Menu + * Opens with START button, shows profile and system settings + */ +@Composable +fun SystemMenu( + isOpen: Boolean, + onDismiss: () -> Unit, + onNavigateRoute: (String) -> Unit, + onLogout: () -> Unit, + onGoOnline: () -> Unit, + isOffline: Boolean = false, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val firstItemFocusRequester = remember { FocusRequester() } + val profileFocusRequester = remember { FocusRequester() } + + var persona by remember { mutableStateOf(null) } + var selectedStatus by remember(persona) { mutableStateOf(persona?.state ?: EPersonaState.Online) } + var showSupporters by remember { mutableStateOf(false) } + var showStatusPicker by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + SteamService.userSteamId?.let { id -> + persona = SteamService.getPersonaStateOf(id) + } + } + + DisposableEffect(true) { + val onPersonaStateReceived: (SteamEvent.PersonaStateReceived) -> Unit = { event -> + Timber.d("SystemMenu onPersonaStateReceived: ${event.persona.state}") + persona = event.persona + selectedStatus = event.persona.state + } + + PluviaApp.events.on(onPersonaStateReceived) + + onDispose { + PluviaApp.events.off(onPersonaStateReceived) + } + } + + BackHandler(enabled = isOpen && showStatusPicker) { + showStatusPicker = false + } + BackHandler(enabled = isOpen && !showStatusPicker) { + onDismiss() + } + + SupportersDialog(visible = showSupporters, onDismiss = { showSupporters = false }) + + val colorOnline = PluviaTheme.colors.statusInstalled + val colorAway = PluviaTheme.colors.statusAway + val colorOffline = PluviaTheme.colors.statusOffline + + val getStatusColor: (EPersonaState) -> Color = { state -> + when (state) { + EPersonaState.Online -> colorOnline + EPersonaState.Away -> colorAway + EPersonaState.Invisible, EPersonaState.Offline -> colorOffline + else -> colorOnline + } + } + + Box(modifier = modifier.fillMaxSize()) { + // Backdrop + AnimatedVisibility( + visible = isOpen, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(150)), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.7f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + ) + } + + // Menu panel - slides from right + AnimatedVisibility( + visible = isOpen, + enter = slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ), + exit = slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + ), + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Surface( + modifier = Modifier + .width(adaptivePanelWidth(380.dp)) + .fillMaxHeight(), + shape = RoundedCornerShape(topStart = 32.dp, bottomStart = 32.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 24.dp, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(24.dp), + ) { + // Header with close button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.system_menu_title), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.options_panel_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Profile section + val profileInteractionSource = remember { MutableInteractionSource() } + val isProfileFocused by profileInteractionSource.collectIsFocusedAsState() + val profileScale by animateFloatAsState( + targetValue = if (isProfileFocused) 1.02f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "profileScale", + ) + + Box { + Row( + modifier = Modifier + .fillMaxWidth() + .scale(profileScale) + .clip(RoundedCornerShape(16.dp)) + .background( + if (isProfileFocused) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + ) + .then( + if (isProfileFocused) { + Modifier.border( + 2.dp, + MaterialTheme.colorScheme.primary, + RoundedCornerShape(16.dp), + ) + } else { + Modifier + }, + ) + .focusRequester(profileFocusRequester) + .selectable( + selected = isProfileFocused, + interactionSource = profileInteractionSource, + indication = null, + onClick = { if (!isOffline) showStatusPicker = !showStatusPicker }, + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Avatar + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (persona?.avatarHash?.isNotEmpty() == true) { + SteamIconImage( + size = 48.dp, + image = { persona?.avatarHash?.getAvatarURL() }, + ) + } else { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Name and status + Column(modifier = Modifier.weight(1f)) { + Text( + text = persona?.name ?: "User", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(getStatusColor(selectedStatus), CircleShape), + ) + Text( + text = when (selectedStatus) { + EPersonaState.Online -> stringResource(R.string.status_online) + EPersonaState.Away -> stringResource(R.string.status_away) + EPersonaState.Invisible -> stringResource(R.string.status_invisible) + else -> selectedStatus.name + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Dropdown indicator (only when online) + if (!isOffline) { + Icon( + imageVector = if (showStatusPicker) Icons.Default.Close else Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Status picker dropdown + androidx.compose.material3.DropdownMenu( + expanded = showStatusPicker, + onDismissRequest = { showStatusPicker = false }, + modifier = Modifier + .width(280.dp) + .background(MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Column( + modifier = Modifier + .padding(8.dp) + .focusGroup(), + ) { + Text( + text = stringResource(R.string.status), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + listOf( + Triple(EPersonaState.Online, stringResource(R.string.status_online), PluviaTheme.colors.statusInstalled), + Triple(EPersonaState.Away, stringResource(R.string.status_away), PluviaTheme.colors.statusAway), + Triple(EPersonaState.Invisible, stringResource(R.string.status_invisible), PluviaTheme.colors.statusOffline), + ).forEach { (state, label, color) -> + StatusOption( + text = label, + statusColor = color, + isSelected = selectedStatus == state, + onClick = { + selectedStatus = state + showStatusPicker = false + scope.launch { + SteamService.setPersonaState(state) + } + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Menu items + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .focusGroup(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + SystemMenuItem( + text = stringResource(R.string.settings_text), + icon = Icons.Default.Settings, + onClick = { + onNavigateRoute(PluviaScreen.Settings.route) + onDismiss() + }, + focusRequester = firstItemFocusRequester, + ) + + SystemMenuItem( + text = stringResource(R.string.help_and_support), + icon = Icons.AutoMirrored.Filled.Help, + onClick = { + uriHandler.openUri("https://discord.gg/2hKv4VfZfE") + }, + ) + + SystemMenuItem( + text = stringResource(R.string.hall_of_fame), + icon = Icons.AutoMirrored.Filled.StarHalf, + onClick = { showSupporters = true }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (isOffline) { + SystemMenuItem( + text = stringResource(R.string.go_online), + icon = Icons.AutoMirrored.Filled.Login, + onClick = { + onGoOnline() + onDismiss() + }, + ) + } else { + SystemMenuItem( + text = stringResource(R.string.go_offline), + icon = Icons.AutoMirrored.Filled.AirplaneTicket, + onClick = { + SteamService.stop() + onNavigateRoute(PluviaScreen.Home.route + "?offline=true") // TODO: test this + onDismiss() + }, + ) + + SystemMenuItem( + text = stringResource(R.string.log_out), + icon = Icons.AutoMirrored.Filled.Logout, + onClick = { + onLogout() + onDismiss() + }, + isDestructive = true, + ) + } + } + + // Gamepad hint at bottom (only on expanded screens) + if (shouldShowGamepadUI()) { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.press_b_to_close), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + } + } + } + } + + // Request focus on first item when menu opens + LaunchedEffect(isOpen) { + if (isOpen) { + try { + firstItemFocusRequester.requestFocus() + } catch (_: Exception) { + // TODO: Focus request may fail if composition is not ready + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + device = "spec:width=1920px,height=1080px,dpi=440,orientation=landscape", +) +@Composable +private fun Preview_SystemMenu() { + val context = LocalContext.current + PrefManager.init(context) + PluviaTheme { + Surface(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + // Fake background content + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Game Library", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } + + SystemMenu( + isOpen = true, + onDismiss = { }, + onNavigateRoute = { }, + onLogout = { }, + onGoOnline = { }, + isOffline = false, + ) + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt index 142649979..da0305400 100644 --- a/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/login/UserLoginScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -61,12 +62,14 @@ 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -85,22 +88,22 @@ import app.gamenative.enums.LoginResult import app.gamenative.enums.LoginScreen import app.gamenative.ui.component.LoadingScreen import app.gamenative.ui.data.UserLoginState +import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.model.UserLoginViewModel import app.gamenative.ui.theme.PluviaTheme -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import app.gamenative.ui.enums.Orientation -import app.gamenative.PluviaApp -import app.gamenative.events.AndroidEvent -import java.util.EnumSet -import android.content.Context -import androidx.compose.material3.FilledTonalButton -import app.gamenative.PrefManager +/** + * Login screen for Steam authentication. + * + * @param connectionState The current connection state to Steam servers (from MainViewModel). + * @param onRetryConnection Called when user wants to retry Steam connection. + * @param onContinueOffline Called when user wants to continue in offline mode. + */ @Composable fun UserLoginScreen( + connectionState: ConnectionState, viewModel: UserLoginViewModel = viewModel(), + onRetryConnection: () -> Unit, onContinueOffline: () -> Unit, ) { val snackBarHostState = remember { SnackbarHostState() } @@ -114,6 +117,7 @@ fun UserLoginScreen( UserLoginScreenContent( snackBarHostState = snackBarHostState, + connectionState = connectionState, userLoginState = userLoginState, onUsername = viewModel::setUsername, onPassword = viewModel::setPassword, @@ -123,7 +127,7 @@ fun UserLoginScreen( onTwoFactorLogin = viewModel::submit, onQrRetry = viewModel::onQrRetry, onSetTwoFactor = viewModel::setTwoFactorCode, - onRetryConnection = viewModel::retryConnection, + onRetryConnection = onRetryConnection, onContinueOffline = onContinueOffline, ) } @@ -132,6 +136,7 @@ fun UserLoginScreen( @Composable private fun UserLoginScreenContent( snackBarHostState: SnackbarHostState, + connectionState: ConnectionState, userLoginState: UserLoginState, onUsername: (String) -> Unit, onPassword: (String) -> Unit, @@ -141,7 +146,7 @@ private fun UserLoginScreenContent( onTwoFactorLogin: () -> Unit, onQrRetry: () -> Unit, onSetTwoFactor: (String) -> Unit, - onRetryConnection: (Context) -> Unit, + onRetryConnection: () -> Unit, onContinueOffline: () -> Unit, ) { val context = LocalContext.current @@ -155,12 +160,12 @@ private fun UserLoginScreenContent( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - .imePadding() + .imePadding(), ) { Column( modifier = Modifier .fillMaxSize() - .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), ) { // Header Row( @@ -168,7 +173,7 @@ private fun UserLoginScreenContent( .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Logo Text( @@ -176,9 +181,9 @@ private fun UserLoginScreenContent( style = MaterialTheme.typography.headlineSmall.copy( fontWeight = FontWeight.Bold, brush = Brush.horizontalGradient( - colors = listOf(primaryColor, tertiaryColor) - ) - ) + colors = listOf(primaryColor, tertiaryColor), + ), + ), ) // Privacy Policy Button @@ -186,11 +191,11 @@ private fun UserLoginScreenContent( TextButton( onClick = { uriHandler.openUri(Constants.Misc.PRIVACY_LINK) }, border = BorderStroke(1.dp, MaterialTheme.colorScheme.surfaceVariant), - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) { Text( text = stringResource(R.string.login_privacy_policy), - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -200,12 +205,12 @@ private fun UserLoginScreenContent( modifier = Modifier .fillMaxWidth() .weight(1f), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { // SnackBar SnackbarHost( hostState = snackBarHostState, - modifier = Modifier.align(Alignment.BottomCenter) + modifier = Modifier.align(Alignment.BottomCenter), ) if ( @@ -220,10 +225,10 @@ private fun UserLoginScreenContent( .width(400.dp) .heightIn(min = 450.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f) + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), ), border = BorderStroke(1.dp, primaryColor.copy(alpha = 0.2f)), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) { // Top gradient border Box( @@ -232,9 +237,9 @@ private fun UserLoginScreenContent( .height(2.dp) .background( brush = Brush.horizontalGradient( - colors = listOf(primaryColor, tertiaryColor, primaryColor) - ) - ) + colors = listOf(primaryColor, tertiaryColor, primaryColor), + ), + ), ) // Make the content scrollable @@ -250,9 +255,9 @@ private fun UserLoginScreenContent( Text( text = stringResource(R.string.login_welcome_back), style = MaterialTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, ), - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) // Subtitle @@ -260,7 +265,7 @@ private fun UserLoginScreenContent( text = stringResource(R.string.login_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 24.dp) + modifier = Modifier.padding(bottom = 24.dp), ) // Tab selection between Credentials and QR Code @@ -269,7 +274,7 @@ private fun UserLoginScreenContent( when (userLoginState.loginScreen) { LoginScreen.QR -> 1 else -> 0 - } + }, ) } @@ -285,10 +290,10 @@ private fun UserLoginScreenContent( TabRowDefaults.Indicator( modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), height = 3.dp, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } - } + }, ) { Tab( selected = selectedTabIndex == 0, @@ -298,13 +303,14 @@ private fun UserLoginScreenContent( }, text = { Text( - "Credentials", - color = if (selectedTabIndex == 0) + stringResource(R.string.login_tab_credentials), + color = if (selectedTabIndex == 0) { MaterialTheme.colorScheme.primary - else + } else { MaterialTheme.colorScheme.onSurfaceVariant + }, ) - } + }, ) Tab( selected = selectedTabIndex == 1, @@ -314,13 +320,14 @@ private fun UserLoginScreenContent( }, text = { Text( - "QR Code", - color = if (selectedTabIndex == 1) + stringResource(R.string.login_tab_qr_code), + color = if (selectedTabIndex == 1) { MaterialTheme.colorScheme.primary - else + } else { MaterialTheme.colorScheme.onSurfaceVariant + }, ) - } + }, ) } @@ -329,17 +336,17 @@ private fun UserLoginScreenContent( // Content based on selected tab Crossfade( targetState = userLoginState.loginScreen, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { screen -> Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 350.dp) + .heightIn(min = 350.dp), ) { when (screen) { LoginScreen.CREDENTIAL -> { - ModernUsernamePassword( - isSteamConnected = userLoginState.isSteamConnected, + CredentialsForm( + connectionState = connectionState, username = userLoginState.username, onUsername = onUsername, password = userLoginState.password, @@ -349,7 +356,6 @@ private fun UserLoginScreenContent( onLoginBtnClick = onCredentialLogin, onRetryConnection = onRetryConnection, onContinueOffline = onContinueOffline, - context = context, ) } @@ -380,10 +386,10 @@ private fun UserLoginScreenContent( } LoginScreen.QR -> { - ModernQRCode( + QRCodeLogin( isQrFailed = userLoginState.isQrFailed, qrCode = userLoginState.qrCode, - onQrRetry = onQrRetry + onQrRetry = onQrRetry, ) } } @@ -392,13 +398,8 @@ private fun UserLoginScreenContent( } } } else { - if (!userLoginState.isSteamConnected) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator() - } - } else { - LoadingScreen() - } + // User is logging in - show appropriate loading state + LoadingScreen() } } } @@ -406,8 +407,8 @@ private fun UserLoginScreenContent( } @Composable -private fun ModernUsernamePassword( - isSteamConnected: Boolean, +private fun CredentialsForm( + connectionState: ConnectionState, username: String, onUsername: (String) -> Unit, password: String, @@ -415,10 +416,11 @@ private fun ModernUsernamePassword( rememberSession: Boolean, onRememberSession: (Boolean) -> Unit, onLoginBtnClick: () -> Unit, - onRetryConnection: (Context) -> Unit, + onRetryConnection: () -> Unit, onContinueOffline: () -> Unit, - context: Context, ) { + val isConnecting = connectionState == ConnectionState.CONNECTING + val isSteamConnected = connectionState == ConnectionState.CONNECTED var passwordVisible by remember { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current val passwordFocusRequester = remember { FocusRequester() } @@ -434,37 +436,66 @@ private fun ModernUsernamePassword( Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp) + .padding(bottom = 16.dp), ) { - if (!isSteamConnected) { + // Show connecting state or disconnected error + if (isConnecting) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .border( + BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), + shape = RoundedCornerShape(16.dp), + ) + .padding(24.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = Color.White, + strokeWidth = 3.dp, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.connecting_to_steam), + color = Color.White.copy(alpha = 0.9f), + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } else if (!isSteamConnected) { + // Show "No connection to Steam" error with retry button Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 16.dp), horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .border( BorderStroke(1.dp, Color.White.copy(alpha = 0.5f)), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) - .padding(24.dp) // Padding inside the border + .padding(24.dp), ) { Text(stringResource(R.string.no_connection_to_steam), color = Color.White) Box(contentAlignment = Alignment.Center) { - OutlinedButton ( - onClick = { onRetryConnection(context) }, + OutlinedButton( + onClick = onRetryConnection, colors = ButtonDefaults.outlinedButtonColors( - // transparent container keeps the outline style containerColor = Color.Transparent, - // secondary-style text instead of primary - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - { + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { Text(stringResource(R.string.retry_steam_connection)) } } @@ -483,7 +514,7 @@ private fun ModernUsernamePassword( text = stringResource(R.string.login_username), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) OutlinedTextField( @@ -495,21 +526,21 @@ private fun ModernUsernamePassword( .border( width = 1.dp, color = MaterialTheme.colorScheme.outline, - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) .focusRequester(usernameFocusRequester), placeholder = { Text( - "Enter your Steam username", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + stringResource(R.string.login_username_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next + imeAction = ImeAction.Next, ), keyboardActions = KeyboardActions( - onNext = { passwordFocusRequester.requestFocus() } + onNext = { passwordFocusRequester.requestFocus() }, ), shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( @@ -518,8 +549,8 @@ private fun ModernUsernamePassword( focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface - ) + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + ), ) } @@ -527,13 +558,13 @@ private fun ModernUsernamePassword( Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp) + .padding(bottom = 16.dp), ) { Text( text = stringResource(R.string.login_password), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp), ) OutlinedTextField( @@ -545,25 +576,25 @@ private fun ModernUsernamePassword( .border( width = 1.dp, color = MaterialTheme.colorScheme.outline, - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(8.dp), ) .focusRequester(passwordFocusRequester), placeholder = { Text( - "Enter your password", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + stringResource(R.string.login_password_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), ) }, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done + imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions( onDone = { keyboardController?.hide() onLoginBtnClick() - } + }, ), shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( @@ -572,7 +603,7 @@ private fun ModernUsernamePassword( focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, ), trailingIcon = { val image = if (passwordVisible) { @@ -581,16 +612,20 @@ private fun ModernUsernamePassword( Icons.Filled.VisibilityOff } - val description = if (passwordVisible) "Hide password" else "Show password" + val description = if (passwordVisible) { + stringResource(R.string.login_password_hide) + } else { + stringResource(R.string.login_password_show) + } IconButton(onClick = { passwordVisible = !passwordVisible }) { Icon( imageVector = image, contentDescription = description, - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } + }, ) } @@ -599,7 +634,7 @@ private fun ModernUsernamePassword( modifier = Modifier .fillMaxWidth() .padding(bottom = 24.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Checkbox( checked = rememberSession, @@ -608,7 +643,7 @@ private fun ModernUsernamePassword( Text( text = stringResource(R.string.login_remember_session), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -627,24 +662,23 @@ private fun ModernUsernamePassword( shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, - disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - ) + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + ), ) { Text( text = stringResource(R.string.login_sign_in), style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, ) } - } } @Composable -private fun ModernQRCode( +private fun QRCodeLogin( isQrFailed: Boolean, qrCode: String?, - onQrRetry: () -> Unit + onQrRetry: () -> Unit, ) { Column( modifier = Modifier @@ -652,14 +686,14 @@ private fun ModernQRCode( .heightIn(min = 350.dp) .padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.Center, ) { if (isQrFailed) { Text( text = stringResource(R.string.login_qr_failed), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(bottom = 16.dp) + modifier = Modifier.padding(bottom = 16.dp), ) OutlinedButton( @@ -668,12 +702,12 @@ private fun ModernQRCode( modifier = Modifier.padding(top = 16.dp), shape = RoundedCornerShape(8.dp), colors = ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.tertiary - ) + contentColor = MaterialTheme.colorScheme.tertiary, + ), ) { Text( text = stringResource(R.string.login_retry_qr), - color = MaterialTheme.colorScheme.tertiary + color = MaterialTheme.colorScheme.tertiary, ) } } else if (qrCode.isNullOrEmpty()) { @@ -681,7 +715,7 @@ private fun ModernQRCode( modifier = Modifier .padding(32.dp) .size(48.dp), - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) } else { // QR Code with fancy border @@ -694,27 +728,27 @@ private fun ModernQRCode( colors = listOf( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.tertiary, - MaterialTheme.colorScheme.primary - ) + MaterialTheme.colorScheme.primary, + ), ), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(16.dp), ) .padding(2.dp), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { Surface( modifier = Modifier.fillMaxSize(), color = Color.White, - shape = RoundedCornerShape(14.dp) + shape = RoundedCornerShape(14.dp), ) { Box( modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { QrCodeImage( modifier = Modifier.fillMaxSize(0.95f), content = qrCode, - size = 200.dp + size = 200.dp, ) } } @@ -727,25 +761,34 @@ private fun ModernQRCode( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp) + modifier = Modifier.padding(horizontal = 16.dp), ) } } } -internal class UserLoginPreview : PreviewParameterProvider { +/** + * Preview data class combining connection state and login state + */ +private data class LoginPreviewData( + val connectionState: ConnectionState, + val loginState: UserLoginState = UserLoginState(), +) + +private class UserLoginPreview : PreviewParameterProvider { override val values = sequenceOf( - UserLoginState(isSteamConnected = true), - UserLoginState(isSteamConnected = true, loginScreen = LoginScreen.QR, qrCode = "Hello World!"), - UserLoginState(isSteamConnected = true, loginScreen = LoginScreen.QR, isQrFailed = true), - UserLoginState(isSteamConnected = false), + LoginPreviewData(ConnectionState.CONNECTED), + LoginPreviewData(ConnectionState.CONNECTED, UserLoginState(loginScreen = LoginScreen.QR, qrCode = "Hello World!")), + LoginPreviewData(ConnectionState.CONNECTED, UserLoginState(loginScreen = LoginScreen.QR, isQrFailed = true)), + LoginPreviewData(ConnectionState.CONNECTING), + LoginPreviewData(ConnectionState.DISCONNECTED), ) } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun Preview_UserLoginScreen( - @PreviewParameter(UserLoginPreview::class) state: UserLoginState, + @PreviewParameter(UserLoginPreview::class) previewData: LoginPreviewData, ) { val snackBarHostState = remember { SnackbarHostState() } @@ -753,7 +796,8 @@ private fun Preview_UserLoginScreen( Surface { UserLoginScreenContent( snackBarHostState = snackBarHostState, - userLoginState = state, + connectionState = previewData.connectionState, + userLoginState = previewData.loginState, onUsername = { }, onPassword = { }, onRememberSession = { }, @@ -762,8 +806,8 @@ private fun Preview_UserLoginScreen( onQrRetry = { }, onSetTwoFactor = { }, onShowLoginScreen = { }, - onRetryConnection = { context -> }, - onContinueOffline = { } + onRetryConnection = { }, + onContinueOffline = { }, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt index 0c71014a6..869733e50 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt @@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,7 +16,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import app.gamenative.CrashHandler import coil.annotation.ExperimentalCoilApi @@ -41,9 +44,11 @@ import app.gamenative.ui.component.dialog.WineDebugChannelsDialog @Composable fun SettingsGroupDebug() { val context = LocalContext.current - // initialize preference managers - PrefManager.init(context) - WinlatorPrefManager.init(context) + val isPreview = LocalInspectionMode.current + if (!isPreview) { + PrefManager.init(context) + WinlatorPrefManager.init(context) + } // Load Wine debug channels and prepare selection state var allWineChannels by remember { mutableStateOf>(emptyList()) } @@ -155,7 +160,10 @@ fun SettingsGroupDebug() { ) } - SettingsGroup(title = { Text(text = stringResource(R.string.settings_debug_title)) }) { + SettingsGroup( + modifier = Modifier.background(Color.Transparent), + title = { Text(text = stringResource(R.string.settings_debug_title)) } + ) { SettingsMenuLink( colors = settingsTileColors(), title = { Text(text = stringResource(R.string.settings_save_logcat_title)) }, @@ -166,7 +174,14 @@ fun SettingsGroupDebug() { SettingsMenuLink( colors = settingsTileColors(), title = { Text(text = stringResource(R.string.settings_debug_wine_channels_title)) }, - subtitle = { Text(text = if (selectedWineChannels.isNotEmpty()) selectedWineChannels.joinToString(",") else "No channels selected") }, + subtitle = { + Text( + text = if (selectedWineChannels.isNotEmpty() && selectedWineChannels.any { it.isNotBlank() }) + selectedWineChannels.filter { it.isNotBlank() }.joinToString(",") + else + stringResource(R.string.settings_debug_no_channels_selected) + ) + }, onClick = { showChannelsDialog = true }, ) SettingsSwitch( @@ -193,12 +208,12 @@ fun SettingsGroupDebug() { colors = settingsTileColors(), title = { Text(text = stringResource(R.string.settings_debug_view_crash_title)) }, subtitle = { - val text = if (latestCrashFile != null) { - "Shows the most recent crash log" - } else { - "No recent crash logs found" - } - Text(text = text) + Text( + text = if (latestCrashFile != null) + stringResource(R.string.settings_debug_view_crash_subtitle) + else + stringResource(R.string.settings_debug_no_crash_logs) + ) }, enabled = latestCrashFile != null, onClick = { showLogcatDialog = true }, @@ -208,12 +223,12 @@ fun SettingsGroupDebug() { colors = settingsTileColors(), title = { Text(text = stringResource(R.string.settings_debug_view_log_title)) }, subtitle = { - val text = if (latestWineLogFile != null) { - "Shows the latest Wine/Box64 debug log" - } else { - "No Wine debug logs found" - } - Text(text = text) + Text( + text = if (latestWineLogFile != null) + stringResource(R.string.settings_debug_view_log_subtitle) + else + stringResource(R.string.settings_debug_no_wine_logs) + ) }, enabled = latestWineLogFile != null, onClick = { showWineLogDialog = true }, diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt index 66b568d2e..c9533c4cd 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupEmulation.kt @@ -1,17 +1,26 @@ package app.gamenative.ui.screen.settings +import android.content.res.Configuration +import androidx.compose.foundation.background import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.ui.component.dialog.Box64PresetsDialog import app.gamenative.ui.component.dialog.ContainerConfigDialog import app.gamenative.ui.component.dialog.FEXCorePresetsDialog import app.gamenative.ui.component.dialog.OrientationDialog +import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.theme.settingsTileColors import app.gamenative.utils.ContainerUtils import com.alorma.compose.settings.ui.SettingsGroup @@ -19,7 +28,10 @@ import com.alorma.compose.settings.ui.SettingsMenuLink @Composable fun SettingsGroupEmulation() { - SettingsGroup(title = { Text(text = stringResource(R.string.settings_emulation_title)) }) { + SettingsGroup( + title = { Text(text = stringResource(R.string.settings_emulation_title)) }, + modifier = Modifier.background(Color.Transparent), + ) { var showConfigDialog by rememberSaveable { mutableStateOf(false) } var showOrientationDialog by rememberSaveable { mutableStateOf(false) } var showBox64PresetsDialog by rememberSaveable { mutableStateOf(false) } @@ -56,17 +68,17 @@ fun SettingsGroupEmulation() { var showDriverManager by rememberSaveable { mutableStateOf(false) } if (showDriverManager) { // Lazy-load dialog composable to avoid cyclic imports - app.gamenative.ui.screen.settings.DriverManagerDialog(open = showDriverManager, onDismiss = { showDriverManager = false }) + DriverManagerDialog(open = showDriverManager, onDismiss = { showDriverManager = false }) } var showContentsManager by rememberSaveable { mutableStateOf(false) } if (showContentsManager) { - app.gamenative.ui.screen.settings.ContentsManagerDialog(open = showContentsManager, onDismiss = { showContentsManager = false }) + ContentsManagerDialog(open = showContentsManager, onDismiss = { showContentsManager = false }) } var showWineProtonManager by rememberSaveable { mutableStateOf(false) } if (showWineProtonManager) { - app.gamenative.ui.screen.settings.WineProtonManagerDialog(open = showWineProtonManager, onDismiss = { showWineProtonManager = false }) + WineProtonManagerDialog(open = showWineProtonManager, onDismiss = { showWineProtonManager = false }) } SettingsMenuLink( @@ -113,3 +125,16 @@ fun SettingsGroupEmulation() { ) } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Composable +private fun Preview_SettingsGroupEmulation() { + val isPreview = LocalInspectionMode.current + if (!isPreview) { + val context = LocalContext.current + PrefManager.init(context) + } + PluviaTheme { + SettingsGroupEmulation() + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInfo.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInfo.kt index 01d157226..2aee44068 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInfo.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInfo.kt @@ -1,5 +1,6 @@ package app.gamenative.ui.screen.settings +import androidx.compose.foundation.background import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MonetizationOn import androidx.compose.material3.Icon @@ -9,6 +10,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import app.gamenative.Constants @@ -23,7 +26,10 @@ import com.alorma.compose.settings.ui.SettingsSwitch @Composable fun SettingsGroupInfo() { - SettingsGroup(title = { Text(text = stringResource(R.string.settings_info_title)) }) { + SettingsGroup( + modifier = Modifier.background(Color.Transparent), + title = { Text(text = stringResource(R.string.settings_info_title)) } + ) { val uriHandler = LocalUriHandler.current var askForTip by rememberSaveable { mutableStateOf(!PrefManager.tipped) } var showLibrariesDialog by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index 0bfe64687..c457a91db 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -3,6 +3,7 @@ package app.gamenative.ui.screen.settings import android.content.res.Configuration import android.os.Environment import android.os.storage.StorageManager +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Arrangement @@ -14,8 +15,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Color import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Map import androidx.compose.material3.Text @@ -25,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import app.gamenative.R import app.gamenative.PrefManager @@ -38,12 +42,11 @@ import kotlinx.serialization.json.Json import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.Color +import app.gamenative.ui.theme.PluviaTheme import androidx.compose.ui.unit.dp import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import app.gamenative.ui.component.settings.SettingsListDropdown -import app.gamenative.ui.theme.PluviaTheme import androidx.compose.ui.viewinterop.AndroidView import android.widget.ImageView import app.gamenative.utils.IconSwitcher @@ -94,7 +97,7 @@ fun SettingsGroupInterface( val languageNames = remember { LocaleHelper.getSupportedLanguageNames() } var selectedLanguageIndex by rememberSaveable { mutableStateOf( - languageCodes.indexOf(PrefManager.appLanguage).takeIf { it >= 0 } ?: 0 + languageCodes.indexOf(PrefManager.appLanguage).takeIf { it >= 0 } ?: 0, ) } @@ -110,11 +113,16 @@ fun SettingsGroupInterface( autoEntries + otherEntries.sortedBy { it.second } } var openRegionDialog by rememberSaveable { mutableStateOf(false) } - var selectedRegionIndex by rememberSaveable { mutableStateOf( - steamRegionsList.indexOfFirst { it.first == PrefManager.cellId }.takeIf { it >= 0 } ?: 0 - ) } + var selectedRegionIndex by rememberSaveable { + mutableStateOf( + steamRegionsList.indexOfFirst { it.first == PrefManager.cellId }.takeIf { it >= 0 } ?: 0, + ) + } - SettingsGroup(title = { Text(text = stringResource(R.string.settings_interface_title)) }) { + SettingsGroup( + modifier = Modifier.background(Color.Transparent), + title = { Text(text = stringResource(R.string.settings_interface_title)) }, + ) { SettingsSwitch( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.settings_interface_external_links_title)) }, @@ -145,13 +153,16 @@ fun SettingsGroupInterface( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.settings_language)) }, subtitle = { Text(text = LocaleHelper.getLanguageDisplayName(PrefManager.appLanguage)) }, - onClick = { openLanguageDialog = true } + onClick = { openLanguageDialog = true }, ) // Unified visual icon picker (affects app and notification icons) var selectedVariant by rememberSaveable { mutableStateOf(if (PrefManager.useAltLauncherIcon || PrefManager.useAltNotificationIcon) 1 else 0) } Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - Text(text = stringResource(R.string.settings_interface_icon_style)) + Text( + text = stringResource(R.string.settings_interface_icon_style), + color = MaterialTheme.colorScheme.onSurface, + ) Spacer(modifier = Modifier.size(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { IconVariantCard( @@ -183,7 +194,10 @@ fun SettingsGroupInterface( } // Downloads settings - SettingsGroup(title = { Text(text = stringResource(R.string.settings_downloads_title)) }) { + SettingsGroup( + modifier = Modifier.background(Color.Transparent), + title = { Text(text = stringResource(R.string.settings_downloads_title)) }, + ) { var wifiOnlyDownload by rememberSaveable { mutableStateOf(PrefManager.downloadOnWifiOnly) } SettingsSwitch( colors = settingsTileColorsAlt(), @@ -201,26 +215,27 @@ fun SettingsGroupInterface( stringResource(R.string.settings_download_slow), stringResource(R.string.settings_download_medium), stringResource(R.string.settings_download_fast), - stringResource(R.string.settings_download_blazing) + stringResource(R.string.settings_download_blazing), ) val downloadSpeedValues = remember { listOf(8, 16, 24, 32) } var downloadSpeedValue by rememberSaveable { mutableStateOf( - downloadSpeedValues.indexOf(PrefManager.downloadSpeed).takeIf { it >= 0 }?.toFloat() ?: 2f + downloadSpeedValues.indexOf(PrefManager.downloadSpeed).takeIf { it >= 0 }?.toFloat() ?: 2f, ) } Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( text = stringResource(R.string.settings_download_speed), - style = androidx.compose.material3.MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.size(4.dp)) Text( text = stringResource(R.string.settings_download_heat_warning), - style = androidx.compose.material3.MaterialTheme.typography.bodySmall, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.size(8.dp)) Slider( @@ -236,15 +251,15 @@ fun SettingsGroupInterface( // Labels below slider Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { downloadSpeedLabels.forEach { label -> Text( text = label, - style = androidx.compose.material3.MaterialTheme.typography.bodySmall, - color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier.width(60.dp) + modifier = Modifier.width(60.dp), ) } } @@ -270,7 +285,7 @@ fun SettingsGroupInterface( var useExternalStorage by rememberSaveable { mutableStateOf(PrefManager.useExternalStorage) } SettingsSwitch( colors = settingsTileColorsAlt(), - enabled = dirs.isNotEmpty(), + enabled = dirs.isNotEmpty(), title = { Text(text = stringResource(R.string.settings_interface_external_storage_title)) }, subtitle = { if (dirs.isEmpty()) @@ -292,7 +307,7 @@ fun SettingsGroupInterface( var selectedIndex by rememberSaveable { mutableStateOf( dirs.indexOfFirst { it.absolutePath == PrefManager.externalStoragePath } - .takeIf { it >= 0 } ?: 0 + .takeIf { it >= 0 } ?: 0, ) } SettingsListDropdown( @@ -303,15 +318,17 @@ fun SettingsGroupInterface( selectedIndex = idx PrefManager.externalStoragePath = dirs[idx].absolutePath }, - colors = settingsTileColorsAlt() + colors = settingsTileColorsAlt(), ) } // Steam download server selection SettingsMenuLink( colors = settingsTileColorsAlt(), title = { Text(text = stringResource(R.string.settings_interface_download_server_title)) }, - subtitle = { Text(text = steamRegionsList.getOrNull(selectedRegionIndex)?.second ?: stringResource(R.string.settings_region_default)) }, - onClick = { openRegionDialog = true } + subtitle = { + Text(text = steamRegionsList.getOrNull(selectedRegionIndex)?.second ?: stringResource(R.string.settings_region_default)) + }, + onClick = { openRegionDialog = true }, ) } @@ -329,7 +346,7 @@ fun SettingsGroupInterface( PrefManager.cellId = selectedId PrefManager.cellIdManuallySet = selectedId != 0 }, - onDismiss = { openRegionDialog = false } + onDismiss = { openRegionDialog = false }, ) // Status bar restart confirmation dialog @@ -358,7 +375,7 @@ fun SettingsGroupInterface( // Revert toggle to original value hideStatusBar = PrefManager.hideStatusBarWhenNotInGame pendingStatusBarValue = null - } + }, ) // Loading dialog while saving and restarting @@ -379,7 +396,7 @@ fun SettingsGroupInterface( LoadingDialog( visible = showStatusBarLoadingDialog, progress = -1f, // Indeterminate progress - message = context.getString(R.string.settings_saving_restarting) + message = context.getString(R.string.settings_saving_restarting), ) // Language selection dialog @@ -400,7 +417,7 @@ fun SettingsGroupInterface( } openLanguageDialog = false }, - onDismiss = { openLanguageDialog = false } + onDismiss = { openLanguageDialog = false }, ) // Language change restart confirmation dialog @@ -429,7 +446,7 @@ fun SettingsGroupInterface( // Revert selection to original value selectedLanguageIndex = languageCodes.indexOf(PrefManager.appLanguage).takeIf { it >= 0 } ?: 0 pendingLanguageCode = null - } + }, ) // Loading dialog while saving and restarting for language change @@ -450,7 +467,7 @@ fun SettingsGroupInterface( LoadingDialog( visible = showLanguageLoadingDialog, progress = -1f, // Indeterminate progress - message = stringResource(R.string.settings_language_changing) + message = stringResource(R.string.settings_language_changing), ) } @@ -463,7 +480,10 @@ private fun IconVariantCard( selected: Boolean, onClick: () -> Unit, ) { - val border = if (selected) BorderStroke(2.dp, Color(0xFF4F46E5)) else BorderStroke(1.dp, Color(0x33404040)) + val border = if (selected) BorderStroke(2.dp, PluviaTheme.colors.accentPurple) else BorderStroke( + 1.dp, + PluviaTheme.colors.borderDefault.copy(alpha = 0.5f), + ) Card( modifier = Modifier .clickable { onClick() }, @@ -497,10 +517,13 @@ private fun IconVariantCard( @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun Preview_SettingsScreen() { - val context = LocalContext.current - PrefManager.init(context) + val isPreview = LocalInspectionMode.current + if (!isPreview) { + val context = LocalContext.current + PrefManager.init(context) + } PluviaTheme { - SettingsGroupInterface ( + SettingsGroupInterface( appTheme = AppTheme.DAY, paletteStyle = PaletteStyle.TonalSpot, onAppTheme = { }, diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt index 9381cc11e..d337e8f28 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsScreen.kt @@ -1,34 +1,65 @@ package app.gamenative.ui.screen.settings import android.content.res.Configuration +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.enums.AppTheme -import app.gamenative.ui.component.topbar.BackButton import app.gamenative.ui.theme.PluviaTheme import com.materialkolor.PaletteStyle -// See link for implementation -// https://github.com/alorma/Compose-Settings - @Composable fun SettingsScreen( appTheme: AppTheme, @@ -46,7 +77,6 @@ fun SettingsScreen( ) } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SettingsScreenContent( appTheme: AppTheme, @@ -55,45 +85,280 @@ private fun SettingsScreenContent( onPaletteStyle: (PaletteStyle) -> Unit, onBack: () -> Unit, ) { - val snackBarHostState = remember { SnackbarHostState() } val scrollState = rememberScrollState() - Scaffold( - snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, - topBar = { - CenterAlignedTopAppBar( - title = { Text(text = stringResource(R.string.settings_title)) }, - navigationIcon = { - BackButton(onClick = onBack) - }, - ) - }, - ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + PluviaTheme.colors.surfacePanel, + MaterialTheme.colorScheme.background, + MaterialTheme.colorScheme.background, + ), + ), + ), + ) { Column( modifier = Modifier - .padding(paddingValues) - .displayCutoutPadding() .fillMaxSize() - .verticalScroll(scrollState), + .statusBarsPadding() + .displayCutoutPadding(), + ) { + SettingsHeader( + onBack = onBack, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + + // Scrollable content + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + // Emulation section + SettingsSection( + title = stringResource(R.string.settings_emulation_title), + icon = Icons.Default.Gamepad, + iconTint = PluviaTheme.colors.accentCyan, + ) { + SettingsGroupEmulation() + } + + // Interface section + SettingsSection( + title = stringResource(R.string.settings_interface_title), + icon = Icons.Default.Palette, + iconTint = PluviaTheme.colors.accentPurple, + ) { + SettingsGroupInterface( + appTheme = appTheme, + paletteStyle = paletteStyle, + onAppTheme = onAppTheme, + onPaletteStyle = onPaletteStyle, + ) + } + + // Info section + SettingsSection( + title = stringResource(R.string.settings_info_title), + icon = Icons.Default.Info, + iconTint = PluviaTheme.colors.accentSuccess, + ) { + SettingsGroupInfo() + } + + // Debug section + SettingsSection( + title = stringResource(R.string.settings_debug_title), + icon = Icons.Default.BugReport, + iconTint = PluviaTheme.colors.accentWarning, + ) { + SettingsGroupDebug() + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } +} + +@Composable +private fun SettingsHeader( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + BackButton(onClick = onBack) + + // Title + Column { + Text( + text = stringResource(R.string.settings_title), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + ), + color = Color.White, + ) + Text( + text = stringResource(R.string.settings_subtitle), + style = MaterialTheme.typography.bodySmall, + color = PluviaTheme.colors.textMuted, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Settings icon decoration + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + PluviaTheme.colors.accentCyan.copy(alpha = 0.2f), + Color.Transparent, + ), + ), + ), + contentAlignment = Alignment.Center, ) { - SettingsGroupEmulation() - SettingsGroupInterface( - appTheme = appTheme, - paletteStyle = paletteStyle, - onAppTheme = onAppTheme, - onPaletteStyle = onPaletteStyle, + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = PluviaTheme.colors.accentCyan.copy(alpha = 0.6f), + modifier = Modifier.size(24.dp), ) - SettingsGroupInfo() - SettingsGroupDebug() + } + } +} + +@Composable +private fun BackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.1f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "backButtonScale", + ) + + Box( + modifier = modifier + .scale(scale) + .size(44.dp) + .clip(CircleShape) + .background( + if (isFocused) { + PluviaTheme.colors.accentCyan.copy(alpha = 0.2f) + } else { + PluviaTheme.colors.surfaceElevated + }, + ) + .then( + if (isFocused) { + Modifier.border( + 2.dp, + PluviaTheme.colors.accentCyan.copy(alpha = 0.6f), + CircleShape, + ) + } else { + Modifier.border( + 1.dp, + PluviaTheme.colors.borderDefault.copy(alpha = 0.3f), + CircleShape, + ) + }, + ) + .selectable( + selected = isFocused, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = stringResource(R.string.back), + tint = if (isFocused) PluviaTheme.colors.accentCyan else Color.White.copy(alpha = 0.8f), + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +private fun SettingsSection( + title: String, + icon: ImageVector, + iconTint: Color, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = PluviaTheme.colors.surfaceElevated, + tonalElevation = 0.dp, + ) { + Column( + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), + ) { + // Section header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Icon with glow background + Box( + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(10.dp)) + .background(iconTint.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.3.sp, + ), + color = Color.White, + ) + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + color = PluviaTheme.colors.borderDefault.copy(alpha = 0.2f), + ) + + // Section content + content() } } } @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + device = "spec:width=1920px,height=1080px,dpi=440,orientation=landscape", +) @Composable private fun Preview_SettingsScreen() { - val context = LocalContext.current - PrefManager.init(context) + val isPreview = LocalInspectionMode.current + if (!isPreview) { + val context = LocalContext.current + PrefManager.init(context) + } PluviaTheme { SettingsScreenContent( appTheme = AppTheme.DAY, diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index dd15a8d08..d56d0de1e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -9,34 +9,26 @@ import android.view.WindowInsets import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.filled.* -import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.LaunchedEffect 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.ui.Alignment import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput @@ -47,30 +39,33 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import app.gamenative.R import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.gamenative.PluviaApp import app.gamenative.PrefManager +import app.gamenative.R import app.gamenative.data.GameSource import app.gamenative.data.LaunchInfo import app.gamenative.data.SteamApp import app.gamenative.events.AndroidEvent import app.gamenative.events.SteamEvent import app.gamenative.service.SteamService -import app.gamenative.ui.component.settings.SettingsListDropdown +import app.gamenative.ui.component.QuickMenu +import app.gamenative.ui.component.QuickMenuAction import app.gamenative.ui.data.XServerState -import app.gamenative.ui.theme.settingsTileColors +import app.gamenative.ui.model.XServerViewModel import app.gamenative.utils.ContainerUtils import app.gamenative.utils.CustomGameScanner import app.gamenative.utils.SteamUtils import com.posthog.PostHog +import com.winlator.PrefManager as WinlatorPrefManager import com.winlator.alsaserver.ALSAClient import com.winlator.container.Container import com.winlator.container.ContainerManager -import com.winlator.contentdialog.NavigationDialog import com.winlator.contents.AdrenotoolsManager import com.winlator.contents.ContentProfile import com.winlator.contents.ContentsManager @@ -91,9 +86,7 @@ import com.winlator.core.WineRegistryEditor import com.winlator.core.WineStartMenuCreator import com.winlator.core.WineThemeManager import com.winlator.core.WineUtils -import com.winlator.core.envvars.EnvVarInfo import com.winlator.core.envvars.EnvVars -import com.winlator.fexcore.FEXCoreManager import com.winlator.inputcontrols.ControllerManager import com.winlator.inputcontrols.ControlsProfile import com.winlator.inputcontrols.ExternalController @@ -125,12 +118,6 @@ import com.winlator.xserver.ScreenInfo import com.winlator.xserver.Window import com.winlator.xserver.WindowManager import com.winlator.xserver.XServer -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.json.JSONException -import org.json.JSONObject -import timber.log.Timber import java.io.File import java.io.IOException import java.nio.file.Files @@ -141,9 +128,15 @@ import java.util.Arrays import java.util.Locale import kotlin.io.path.name import kotlin.text.lowercase -import com.winlator.PrefManager as WinlatorPrefManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber -// TODO logs in composables are 'unstable' which can cause recomposition (performance issues) +// TODO: logs in composables are 'unstable' which can cause recomposition (performance issues) +// TODO: this needs a bigger refactor/clean-up (logging, abstractions, state management, etc) @Composable @OptIn(ExperimentalComposeUiApi::class) @@ -151,12 +144,13 @@ fun XServerScreen( lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, appId: String, bootToContainer: Boolean, - registerBackAction: ( ( ) -> Unit ) -> Unit, + registerBackAction: (() -> Unit) -> Unit, navigateBack: () -> Unit, onExit: () -> Unit, onWindowMapped: ((Context, Window) -> Unit)? = null, onWindowUnmapped: ((Window) -> Unit)? = null, onGameLaunchError: ((String) -> Unit)? = null, + viewModel: XServerViewModel = hiltViewModel(), ) { Timber.i("Starting up XServerScreen") val context = LocalContext.current @@ -165,6 +159,9 @@ fun XServerScreen( context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager } + // Collect ViewModel state + val xServerState by viewModel.state.collectAsStateWithLifecycle() + // PluviaApp.events.emit(AndroidEvent.SetAppBarVisibility(false)) PluviaApp.events.emit(AndroidEvent.SetSystemUIVisibility(false)) PluviaApp.events.emit( @@ -186,21 +183,20 @@ fun XServerScreen( ContainerUtils.getContainer(context, appId) } - val xServerState = rememberSaveable(stateSaver = XServerState.Saver) { - mutableStateOf( - XServerState( - graphicsDriver = container.graphicsDriver, - graphicsDriverVersion = container.graphicsDriverVersion, - audioDriver = container.audioDriver, - dxwrapper = container.dxWrapper, - dxwrapperConfig = DXVKHelper.parseConfig(container.dxWrapperConfig), - screenSize = container.screenSize, - ), + // Initialize ViewModel with container config on first composition + LaunchedEffect(container) { + viewModel.initializeFromContainer( + graphicsDriver = container.graphicsDriver, + graphicsDriverVersion = container.graphicsDriverVersion, + audioDriver = container.audioDriver, + dxwrapper = container.dxWrapper, + dxwrapperConfig = DXVKHelper.parseConfig(container.dxWrapperConfig), + screenSize = container.screenSize, ) } - // val xServer by remember { - // val result = mutableStateOf(XServer(ScreenInfo(xServerState.value.screenSize))) + // var xServer by remember { + // val result = mutableStateOf(XServer(ScreenInfo(xServerState.screenSize))) // Log.d("XServerScreen", "Remembering xServer as $result") // result // } @@ -233,13 +229,16 @@ fun XServerScreen( var win32AppWorkarounds: Win32AppWorkarounds? by remember { mutableStateOf(null) } var isKeyboardVisible = false - var areControlsVisible by remember { mutableStateOf(false) } - var isEditMode by remember { mutableStateOf(false) } - // Snapshot of element positions before entering edit mode (for cancel behavior) - var elementPositionsSnapshot by remember { mutableStateOf>>(emptyMap()) } - var showElementEditor by remember { mutableStateOf(false) } - var elementToEdit by remember { mutableStateOf(null) } - var showPhysicalControllerDialog by remember { mutableStateOf(false) } + + // UI state is now managed by ViewModel - extract for local use + val areControlsVisible = xServerState.areControlsVisible + val isEditMode = xServerState.isEditMode + val elementPositionsSnapshot = xServerState.elementPositionsSnapshot + val showElementEditor = xServerState.showElementEditor + val elementToEdit = xServerState.elementToEdit + val showPhysicalControllerDialog = xServerState.showPhysicalControllerDialog + val showQuickMenu = xServerState.showQuickMenu + val hasPhysicalController = xServerState.hasPhysicalController val gameBack: () -> Unit = gameBack@{ val imeVisible = ViewCompat.getRootWindowInsets(view) @@ -258,151 +257,135 @@ fun XServerScreen( return@gameBack } - Timber.i("BackHandler") - NavigationDialog( - context, - object : NavigationDialog.NavigationListener { - override fun onNavigationItemSelected(itemId: Int) { - when (itemId) { - NavigationDialog.ACTION_KEYBOARD -> { - val anchor = view // use the same composable root view - val c = if (Build.VERSION.SDK_INT >= 30) - anchor.windowInsetsController else null - - anchor.post { - if (anchor.windowToken == null) return@post - val show = { - PostHog.capture(event = "onscreen_keyboard_enabled") - imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) - } - if (Build.VERSION.SDK_INT > 29 && c != null) { - anchor.postDelayed({ show() }, 500) // Pixel/Android-12+ quirk - } else { - show() - } - } - } + Timber.i("BackHandler - Showing QuickMenu") + viewModel.setShowQuickMenu(true) + } - NavigationDialog.ACTION_INPUT_CONTROLS -> { - if (areControlsVisible){ - PostHog.capture(event = "onscreen_controller_disabled") - hideInputControls(); - } else { - PostHog.capture(event = "onscreen_controller_enabled") - val manager = PluviaApp.inputControlsManager - val profiles = manager?.getProfiles(false) ?: listOf() - if (profiles.isNotEmpty()) { - // Use current profile (custom or Profile 0) - val profileIdStr = container.getExtra("profileId", "0") - val profileId = profileIdStr.toIntOrNull() ?: 0 - val targetProfile = if (profileId != 0) { - manager?.getProfile(profileId) - } else { - null - } ?: manager?.getProfile(0) ?: profiles.getOrNull(2) ?: profiles.first() - - showInputControls(targetProfile, xServerView!!.getxServer().winHandler, container) - } - } - areControlsVisible = !areControlsVisible - } + // Handler for QuickMenu item selection + val onQuickMenuItemSelected: (Int) -> Unit = { itemId -> + when (itemId) { + QuickMenuAction.KEYBOARD -> { + val anchor = view + val c = if (Build.VERSION.SDK_INT >= 30) { + anchor.windowInsetsController + } else { + null + } - NavigationDialog.ACTION_EDIT_CONTROLS -> { - PostHog.capture(event = "edit_controls_in_game") + anchor.post { + if (anchor.windowToken == null) return@post + val show = { + PostHog.capture(event = "onscreen_keyboard_enabled") + imm.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) + } + if (Build.VERSION.SDK_INT > 29 && c != null) { + anchor.postDelayed({ show() }, 500) + } else { + show() + } + } + } - // Get or create profile for this container - val manager = PluviaApp.inputControlsManager ?: InputControlsManager(context) - val allProfiles = manager.getProfiles(false) + QuickMenuAction.INPUT_CONTROLS -> { + if (areControlsVisible) { + PostHog.capture(event = "onscreen_controller_disabled") + hideInputControls() + } else { + PostHog.capture(event = "onscreen_controller_enabled") + val manager = PluviaApp.inputControlsManager + val profiles = manager?.getProfiles(false) ?: listOf() + if (profiles.isNotEmpty()) { + val profileIdStr = container.getExtra("profileId", "0") + val profileId = profileIdStr.toIntOrNull() ?: 0 + val targetProfile = if (profileId != 0) { + manager?.getProfile(profileId) + } else { + null + } ?: manager?.getProfile(0) ?: profiles.getOrNull(2) ?: profiles.first() - val profileIdStr = container.getExtra("profileId", "0") - val profileId = profileIdStr.toIntOrNull() ?: 0 + showInputControls(targetProfile, xServerView!!.getxServer().winHandler, container) + } + } + viewModel.toggleControlsVisible() + } - var activeProfile = if (profileId != 0) { - manager.getProfile(profileId) - } else { - null - } + QuickMenuAction.EDIT_CONTROLS -> { + PostHog.capture(event = "edit_controls_in_game") - // If no custom profile exists, create one automatically - if (activeProfile == null) { - val sourceProfile = manager.getProfile(0) - ?: allProfiles.firstOrNull { it.id == 2 } - ?: allProfiles.firstOrNull() - - if (sourceProfile != null) { - try { - // Create game-specific profile by duplicating Profile 0 - activeProfile = manager.duplicateProfile(sourceProfile) - - // Rename to game name - val gameName = currentAppInfo?.name ?: container.name - activeProfile.setName("$gameName - Controls") - activeProfile.save() - - // Associate with container using extraData and save - container.putExtra("profileId", activeProfile.id.toString()) - container.saveData() - - // Apply the new profile to InputControlsView - PluviaApp.inputControlsView?.setProfile(activeProfile) - } catch (e: Exception) { - Timber.e(e, "Failed to auto-create profile for container %s", container.name) - // Fallback to existing profile - activeProfile = sourceProfile - } - } - } + val manager = PluviaApp.inputControlsManager ?: InputControlsManager(context) + val allProfiles = manager.getProfiles(false) - // Enable edit mode and show controls if not visible - if (activeProfile != null) { - // Capture snapshot of element positions before entering edit mode - val profile = PluviaApp.inputControlsView?.profile - if (profile != null) { - val snapshot = mutableMapOf>() - profile.elements.forEach { element -> - snapshot[element] = Pair(element.x.toInt(), element.y.toInt()) - } - elementPositionsSnapshot = snapshot - } + val profileIdStr = container.getExtra("profileId", "0") + val profileId = profileIdStr.toIntOrNull() ?: 0 - isEditMode = true - PluviaApp.inputControlsView?.setEditMode(true) - PluviaApp.inputControlsView?.let { icView -> - // Wait for view to be laid out before loading elements - icView.post { - activeProfile.loadElements(icView) - } - } + var activeProfile = if (profileId != 0) { + manager.getProfile(profileId) + } else { + null + } - if (!areControlsVisible) { - showInputControls(activeProfile, xServerView!!.getxServer().winHandler, container) - areControlsVisible = true - } - } - } + if (activeProfile == null) { + val sourceProfile = manager.getProfile(0) + ?: allProfiles.firstOrNull { it.id == 2 } + ?: allProfiles.firstOrNull() - NavigationDialog.ACTION_EDIT_PHYSICAL_CONTROLLER -> { - PostHog.capture(event = "edit_physical_controller_from_menu") - showPhysicalControllerDialog = true + if (sourceProfile != null) { + try { + activeProfile = manager.duplicateProfile(sourceProfile) + val gameName = currentAppInfo?.name ?: container.name + activeProfile.setName("$gameName - Controls") + activeProfile.save() + container.putExtra("profileId", activeProfile.id.toString()) + container.saveData() + PluviaApp.inputControlsView?.setProfile(activeProfile) + } catch (e: Exception) { + Timber.e(e, "Failed to auto-create profile for container %s", container.name) + activeProfile = sourceProfile } + } + } - NavigationDialog.ACTION_EXIT_GAME -> { - if (currentAppInfo != null) { - PostHog.capture( - event = "game_closed", - properties = mapOf( - "game_name" to currentAppInfo.name, - ), - ) - } else { - PostHog.capture(event = "game_closed") - } - exit(xServerView!!.getxServer().winHandler, PluviaApp.xEnvironment, frameRating, currentAppInfo, container, onExit, navigateBack) + if (activeProfile != null) { + val profile = PluviaApp.inputControlsView?.profile + if (profile != null) { + viewModel.enterEditMode(profile.elements.toList()) + } else { + viewModel.setEditMode(true) + } + + PluviaApp.inputControlsView?.setEditMode(true) + PluviaApp.inputControlsView?.let { icView -> + icView.post { + activeProfile.loadElements(icView) } } + + if (!areControlsVisible) { + showInputControls(activeProfile, xServerView!!.getxServer().winHandler, container) + viewModel.setControlsVisible(true) + } } } - ).show() + + QuickMenuAction.EDIT_PHYSICAL_CONTROLLER -> { + PostHog.capture(event = "edit_physical_controller_from_menu") + viewModel.setShowPhysicalControllerDialog(true) + } + + QuickMenuAction.EXIT_GAME -> { + if (currentAppInfo != null) { + PostHog.capture( + event = "game_closed", + properties = mapOf( + "game_name" to currentAppInfo.name, + ), + ) + } else { + PostHog.capture(event = "game_closed") + } + exit(xServerView!!.getxServer().winHandler, PluviaApp.xEnvironment, frameRating, currentAppInfo, container, onExit, navigateBack) + } + } } DisposableEffect(container) { @@ -410,7 +393,7 @@ fun XServerScreen( onDispose { Timber.d("XServerScreen leaving, clearing back action") registerBackAction { } - } // reset when screen leaves + } // reset when screen leaves } DisposableEffect(lifecycleOwner, container) { @@ -480,396 +463,408 @@ fun XServerScreen( // var launchedView by rememberSaveable { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { AndroidView( - modifier = Modifier - .fillMaxSize() - .pointerHoverIcon(PointerIcon(0)) - .pointerInteropFilter { - // If controls are visible, let them handle it first - val controlsHandled = if (areControlsVisible) { - PluviaApp.inputControlsView?.onTouchEvent(it) ?: false - } else { - false - } + modifier = Modifier + .fillMaxSize() + .pointerHoverIcon(PointerIcon(0)) + .pointerInteropFilter { + // If controls are visible, let them handle it first + val controlsHandled = if (areControlsVisible) { + PluviaApp.inputControlsView?.onTouchEvent(it) ?: false + } else { + false + } - // If controls didn't handle it or aren't visible, send to touchMouse - if (!controlsHandled) { - PluviaApp.touchpadView?.onTouchEvent(it) - } + // If controls didn't handle it or aren't visible, send to touchMouse + if (!controlsHandled) { + PluviaApp.touchpadView?.onTouchEvent(it) + } - true - }, - factory = { context -> - Timber.i("Creating XServerView and XServer") - val frameLayout = FrameLayout(context) - val appId = appId - val existingXServer = - PluviaApp.xEnvironment - ?.getComponent(XServerComponent::class.java) - ?.xServer - val xServerToUse = existingXServer ?: XServer(ScreenInfo(xServerState.value.screenSize)) - val xServerView = XServerView( - context, - xServerToUse, - ).apply { - xServerView = this - val renderer = this.renderer - renderer.isCursorVisible = false - getxServer().renderer = renderer - PluviaApp.touchpadView = TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true)) - frameLayout.addView(PluviaApp.touchpadView) - PluviaApp.touchpadView?.setMoveCursorToTouchpoint(PrefManager.getBoolean("move_cursor_to_touchpoint", false)) - getxServer().winHandler = WinHandler(getxServer(), this) - win32AppWorkarounds = Win32AppWorkarounds( - getxServer(), - taskAffinityMask, - taskAffinityMaskWoW64, - ) - touchMouse = TouchMouse(getxServer()) - keyboard = Keyboard(getxServer()) - if (!bootToContainer) { - renderer.setUnviewableWMClasses("explorer.exe") - // TODO: make 'force fullscreen' be an option of the app being launched - appLaunchInfo?.let { renderer.forceFullscreenWMClass = Paths.get(it.executable).name } - } - getxServer().windowManager.addOnWindowModificationListener( - object : WindowManager.OnWindowModificationListener { - private fun changeFrameRatingVisibility(window: Window, property: Property?) { - if (frameRating == null) return - if (property != null) { - if (frameRatingWindowId == -1 && ( + true + }, + factory = { context -> + Timber.i("Creating XServerView and XServer") + val frameLayout = FrameLayout(context) + val appId = appId + val existingXServer = + PluviaApp.xEnvironment + ?.getComponent(XServerComponent::class.java) + ?.xServer + val xServerToUse = existingXServer ?: XServer(ScreenInfo(xServerState.screenSize)) + val xServerView = XServerView( + context, + xServerToUse, + ).apply { + xServerView = this + val renderer = this.renderer + renderer.isCursorVisible = false + getxServer().renderer = renderer + PluviaApp.touchpadView = + TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true)) + frameLayout.addView(PluviaApp.touchpadView) + PluviaApp.touchpadView?.setMoveCursorToTouchpoint(PrefManager.getBoolean("move_cursor_to_touchpoint", false)) + getxServer().winHandler = WinHandler(getxServer(), this) + win32AppWorkarounds = Win32AppWorkarounds( + getxServer(), + taskAffinityMask, + taskAffinityMaskWoW64, + ) + touchMouse = TouchMouse(getxServer()) + keyboard = Keyboard(getxServer()) + if (!bootToContainer) { + renderer.setUnviewableWMClasses("explorer.exe") + // TODO: make 'force fullscreen' be an option of the app being launched + appLaunchInfo?.let { renderer.forceFullscreenWMClass = Paths.get(it.executable).name } + } + getxServer().windowManager.addOnWindowModificationListener( + object : WindowManager.OnWindowModificationListener { + private fun changeFrameRatingVisibility(window: Window, property: Property?) { + if (frameRating == null) return + if (property != null) { + if (frameRatingWindowId == -1 && + ( property.nameAsString().contains("_UTIL_LAYER") || - property.nameAsString().contains("_MESA_DRV") || - container.containerVariant.equals(Container.GLIBC) && property.nameAsString().contains("_NET_WM_SURFACE"))) { - frameRatingWindowId = window.id + property.nameAsString().contains("_MESA_DRV") || + container.containerVariant.equals(Container.GLIBC) && + property.nameAsString().contains("_NET_WM_SURFACE") + ) + ) { + frameRatingWindowId = window.id + (context as? Activity)?.runOnUiThread { + frameRating?.visibility = View.VISIBLE + } + frameRating?.update() + } + } else if (frameRatingWindowId != -1) { + frameRatingWindowId = -1 (context as? Activity)?.runOnUiThread { - frameRating?.visibility = View.VISIBLE + frameRating?.visibility = View.GONE } - frameRating?.update() - } - } else if (frameRatingWindowId != -1) { - frameRatingWindowId = -1 - (context as? Activity)?.runOnUiThread { - frameRating?.visibility = View.GONE } } - } - override fun onUpdateWindowContent(window: Window) { - if (!xServerState.value.winStarted && window.isApplicationWindow()) { - if (!container.isDisableMouseInput && !container.isTouchscreenMode) renderer?.setCursorVisible(true) - xServerState.value.winStarted = true - } - if (window.id == frameRatingWindowId) { - (context as? Activity)?.runOnUiThread { - frameRating?.update() + override fun onUpdateWindowContent(window: Window) { + if (!xServerState.winStarted && window.isApplicationWindow()) { + if (!container.isDisableMouseInput && !container.isTouchscreenMode) renderer?.setCursorVisible(true) + viewModel.setWinStarted(true) + } + if (window.id == frameRatingWindowId) { + (context as? Activity)?.runOnUiThread { + frameRating?.update() + } } } - } - override fun onModifyWindowProperty(window: Window, property: Property) { - changeFrameRatingVisibility(window, property) - } + override fun onModifyWindowProperty(window: Window, property: Property) { + changeFrameRatingVisibility(window, property) + } - override fun onMapWindow(window: Window) { - Timber.i( - "onMapWindow:" + + override fun onMapWindow(window: Window) { + Timber.i( + "onMapWindow:" + "\n\twindowName: ${window.name}" + "\n\twindowClassName: ${window.className}" + "\n\tprocessId: ${window.processId}" + "\n\thasParent: ${window.parent != null}" + "\n\tchildrenSize: ${window.children.size}", - ) - win32AppWorkarounds?.applyWindowWorkarounds(window) - onWindowMapped?.invoke(context, window) - } + ) + win32AppWorkarounds?.applyWindowWorkarounds(window) + onWindowMapped?.invoke(context, window) + } - override fun onUnmapWindow(window: Window) { - Timber.i( - "onUnmapWindow:" + + override fun onUnmapWindow(window: Window) { + Timber.i( + "onUnmapWindow:" + "\n\twindowName: ${window.name}" + "\n\twindowClassName: ${window.className}" + "\n\tprocessId: ${window.processId}" + "\n\thasParent: ${window.parent != null}" + "\n\tchildrenSize: ${window.children.size}", - ) - changeFrameRatingVisibility(window, null) - onWindowUnmapped?.invoke(window) - } - }, - ) - - if (PluviaApp.xEnvironment == null) { - // Launch all blocking wine setup operations on a background thread to avoid blocking main thread - val setupExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { r -> - Thread(r, "WineSetup-Thread").apply { isDaemon = false } - } - - setupExecutor.submit { - try { - val containerManager = ContainerManager(context) - // Configure WinHandler with container's input API settings - val handler = getxServer().winHandler - if (container.inputType !in 0..3) { - container.inputType = PreferredInputApi.BOTH.ordinal - container.saveData() - } - handler.setPreferredInputApi(PreferredInputApi.values()[container.inputType]) - handler.setDInputMapperType(container.dinputMapperType) - if (container.isDisableMouseInput()) { - PluviaApp.touchpadView?.setTouchscreenMouseDisabled(true) - } else if (container.isTouchscreenMode()) { - PluviaApp.touchpadView?.setTouchscreenMode(true) + ) + changeFrameRatingVisibility(window, null) + onWindowUnmapped?.invoke(window) } - Timber.d("WinHandler configured: preferredInputApi=%s, dinputMapperType=0x%02x", PreferredInputApi.values()[container.inputType], container.dinputMapperType) - // Timber.d("1 Container drives: ${container.drives}") - containerManager.activateContainer(container) - // Timber.d("2 Container drives: ${container.drives}") - val imageFs = ImageFs.find(context) - - taskAffinityMask = ProcessHelper.getAffinityMask(container.getCPUList(true)).toShort().toInt() - taskAffinityMaskWoW64 = ProcessHelper.getAffinityMask(container.getCPUListWoW64(true)).toShort().toInt() - containerVariantChanged = container.containerVariant != imageFs.variant - firstTimeBoot = container.getExtra("appVersion").isEmpty() || containerVariantChanged - needsUnpacking = container.isNeedsUnpacking - Timber.i("First time boot: $firstTimeBoot") - - val wineVersion = container.wineVersion - Timber.i("Wine version is: $wineVersion") - val contentsManager = ContentsManager(context) - contentsManager.syncContents() - Timber.i("Wine info is: " + WineInfo.fromIdentifier(context, contentsManager, wineVersion)) - xServerState.value = xServerState.value.copy( - wineInfo = WineInfo.fromIdentifier(context, contentsManager, wineVersion), - ) - Timber.i("xServerState.value.wineInfo is: " + xServerState.value.wineInfo) - Timber.i("WineInfo.MAIN_WINE_VERSION is: " + WineInfo.MAIN_WINE_VERSION) - Timber.i("Wine path for wineinfo is " + xServerState.value.wineInfo.path) + }, + ) - if (!xServerState.value.wineInfo.isMainWineVersion()) { - Timber.i("Settings wine path to: ${xServerState.value.wineInfo.path}") - imageFs.setWinePath(xServerState.value.wineInfo.path) - } else { - imageFs.setWinePath(imageFs.rootDir.path + "/opt/wine") - } + if (PluviaApp.xEnvironment == null) { + // Launch all blocking wine setup operations on a background thread to avoid blocking main thread + val setupExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { r -> + Thread(r, "WineSetup-Thread").apply { isDaemon = false } + } - val onExtractFileListener = if (!xServerState.value.wineInfo.isWin64) { - object : OnExtractFileListener { - override fun onExtractFile(destination: File?, size: Long): File? { - return destination?.path?.let { - if (it.contains("system32/")) { - null - } else { - File(it.replace("syswow64/", "system32/")) + setupExecutor.submit { + try { + val containerManager = ContainerManager(context) + // Configure WinHandler with container's input API settings + val handler = getxServer().winHandler + if (container.inputType !in 0..3) { + container.inputType = PreferredInputApi.BOTH.ordinal + container.saveData() + } + handler.setPreferredInputApi(PreferredInputApi.values()[container.inputType]) + handler.setDInputMapperType(container.dinputMapperType) + if (container.isDisableMouseInput()) { + PluviaApp.touchpadView?.setTouchscreenMouseDisabled(true) + } else if (container.isTouchscreenMode()) { + PluviaApp.touchpadView?.setTouchscreenMode(true) + } + Timber.d("WinHandler configured: preferredInputApi=%s, dinputMapperType=0x%02x", PreferredInputApi.values()[container.inputType], container.dinputMapperType) + // Timber.d("1 Container drives: ${container.drives}") + containerManager.activateContainer(container) + // Timber.d("2 Container drives: ${container.drives}") + val imageFs = ImageFs.find(context) + + taskAffinityMask = ProcessHelper.getAffinityMask(container.getCPUList(true)).toShort().toInt() + taskAffinityMaskWoW64 = ProcessHelper.getAffinityMask(container.getCPUListWoW64(true)).toShort().toInt() + containerVariantChanged = container.containerVariant != imageFs.variant + firstTimeBoot = container.getExtra("appVersion").isEmpty() || containerVariantChanged + needsUnpacking = container.isNeedsUnpacking + Timber.i("First time boot: $firstTimeBoot") + + val wineVersion = container.wineVersion + Timber.i("Wine version is: $wineVersion") + val contentsManager = ContentsManager(context) + contentsManager.syncContents() + Timber.i("Wine info is: " + WineInfo.fromIdentifier(context, contentsManager, wineVersion)) + viewModel.setWineInfo(WineInfo.fromIdentifier(context, contentsManager, wineVersion)) + Timber.i("xServerState.wineInfo is: " + xServerState.wineInfo) + Timber.i("WineInfo.MAIN_WINE_VERSION is: " + WineInfo.MAIN_WINE_VERSION) + Timber.i("Wine path for wineinfo is " + xServerState.wineInfo.path) + + if (!xServerState.wineInfo.isMainWineVersion()) { + Timber.i("Settings wine path to: ${xServerState.wineInfo.path}") + imageFs.setWinePath(xServerState.wineInfo.path) + } else { + imageFs.setWinePath(imageFs.rootDir.path + "/opt/wine") + } + + val onExtractFileListener = if (!xServerState.wineInfo.isWin64) { + object : OnExtractFileListener { + override fun onExtractFile(destination: File?, size: Long): File? { + return destination?.path?.let { + if (it.contains("system32/")) { + null + } else { + File(it.replace("syswow64/", "system32/")) + } } } } + } else { + null } - } else { - null - } - val sharpnessEffect: String = container.getExtra("sharpnessEffect", "None") - if (sharpnessEffect != "None") { - val sharpnessLevel = container.getExtra("sharpnessLevel", "100").toDouble() - val sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toDouble() - vkbasaltConfig = - "effects=" + sharpnessEffect.lowercase(Locale.getDefault()) + ";" + "casSharpness=" + sharpnessLevel / 100 + ";" + "dlsSharpness=" + sharpnessLevel / 100 + ";" + "dlsDenoise=" + sharpnessDenoise / 100 + ";" + "enableOnLaunch=True" - } + val sharpnessEffect: String = container.getExtra("sharpnessEffect", "None") + if (sharpnessEffect != "None") { + val sharpnessLevel = container.getExtra("sharpnessLevel", "100").toDouble() + val sharpnessDenoise = container.getExtra("sharpnessDenoise", "100").toDouble() + vkbasaltConfig = + "effects=" + sharpnessEffect.lowercase(Locale.getDefault()) + ";" + "casSharpness=" + + sharpnessLevel / 100 + + ";" + + "dlsSharpness=" + + sharpnessLevel / 100 + + ";" + + "dlsDenoise=" + + sharpnessDenoise / 100 + + ";" + + "enableOnLaunch=True" + } - Timber.i("Doing things once") - val envVars = EnvVars() - - setupWineSystemFiles( - context, - firstTimeBoot, - xServerView!!.getxServer().screenInfo, - xServerState, - container, - containerManager, - envVars, - contentsManager, - onExtractFileListener, - ) - extractArm64ecInputDLLs(context, container) // REQUIRED: Uses updated xinput1_3 main.c from x86_64 build, prevents crashes with 3+ players, avoids need for input shim dlls. - extractx86_64InputDlls(context, container) - extractGraphicsDriverFiles( - context, - xServerState.value.graphicsDriver, - xServerState.value.dxwrapper, - xServerState.value.dxwrapperConfig!!, - container, - envVars, - firstTimeBoot, - vkbasaltConfig, - ) - changeWineAudioDriver(xServerState.value.audioDriver, container, ImageFs.find(context)) - setImagefsContainerVariant(context, container) - PluviaApp.xEnvironment = setupXEnvironment( - context, - appId, - bootToContainer, - xServerState, - envVars, - container, - appLaunchInfo, - xServerView!!.getxServer(), - containerVariantChanged, - onGameLaunchError, - navigateBack, - ) - } catch (e: Exception) { - Timber.e(e, "Error during wine setup operations") - onGameLaunchError?.invoke("Failed to setup wine: ${e.message}") - } finally { - setupExecutor.shutdown() + Timber.i("Doing things once") + val envVars = EnvVars() + + setupWineSystemFiles( + context, + firstTimeBoot, + xServerView!!.getxServer().screenInfo, + xServerState, + viewModel, + container, + containerManager, + envVars, + contentsManager, + onExtractFileListener, + ) + extractArm64ecInputDLLs(context, container) // REQUIRED: Uses updated xinput1_3 main.c from x86_64 build, prevents crashes with 3+ players, avoids need for input shim dlls. + extractx86_64InputDlls(context, container) + extractGraphicsDriverFiles( + context, + xServerState.graphicsDriver, + xServerState.dxwrapper, + xServerState.dxwrapperConfig!!, + container, + envVars, + firstTimeBoot, + vkbasaltConfig, + ) + changeWineAudioDriver(xServerState.audioDriver, container, ImageFs.find(context)) + setImagefsContainerVariant(context, container) + PluviaApp.xEnvironment = setupXEnvironment( + context, + appId, + bootToContainer, + xServerState, + viewModel, + envVars, + container, + appLaunchInfo, + xServerView!!.getxServer(), + containerVariantChanged, + onGameLaunchError, + navigateBack, + ) + } catch (e: Exception) { + Timber.e(e, "Error during wine setup operations") + onGameLaunchError?.invoke("Failed to setup wine: ${e.message}") + } finally { + setupExecutor.shutdown() + } } } } - } - PluviaApp.xServerView = xServerView; - - frameLayout.addView(xServerView) + PluviaApp.xServerView = xServerView - PluviaApp.inputControlsManager = InputControlsManager(context) + frameLayout.addView(xServerView) - // Store the loaded profile for auto-show logic later (declared outside apply block) - var loadedProfile: ControlsProfile? = null + PluviaApp.inputControlsManager = InputControlsManager(context) - // Create InputControlsView and add to FrameLayout - val icView = InputControlsView(context).apply { - // Configure InputControlsView - setXServer(xServerView.getxServer()) - setTouchpadView(PluviaApp.touchpadView) + // Store the loaded profile for auto-show logic later (declared outside apply block) + var loadedProfile: ControlsProfile? = null - // Load profile for this container - val manager = PluviaApp.inputControlsManager - val profiles = manager?.getProfiles(false) ?: listOf() - PrefManager.init(context) + // Create InputControlsView and add to FrameLayout + val icView = InputControlsView(context).apply { + // Configure InputControlsView + setXServer(xServerView.getxServer()) + setTouchpadView(PluviaApp.touchpadView) - if (profiles.isNotEmpty()) { - // Check if container has a custom profile associated - val profileIdStr = container.getExtra("profileId", "0") - val profileId = profileIdStr.toIntOrNull() ?: 0 - Timber.d("=== Profile Loading Start ===") - Timber.d("Container: ${container.name}, ProfileID from extra: $profileId") + // Load profile for this container + val manager = PluviaApp.inputControlsManager + val profiles = manager?.getProfiles(false) ?: listOf() + PrefManager.init(context) + + if (profiles.isNotEmpty()) { + // Check if container has a custom profile associated + val profileIdStr = container.getExtra("profileId", "0") + val profileId = profileIdStr.toIntOrNull() ?: 0 + Timber.d("=== Profile Loading Start ===") + Timber.d("Container: ${container.name}, ProfileID from extra: $profileId") + + val customProfile = if (profileId != 0) manager?.getProfile(profileId) else null + + val targetProfile = if (customProfile != null) { + // Use the custom profile associated with this container + Timber.d("Using CUSTOM profile: ${customProfile.name} (ID: ${customProfile.id})") + customProfile + } else { + // Use Profile 0 (Physical Controller Default) as fallback + val fallback = manager?.getProfile(0) ?: profiles.getOrNull(2) ?: profiles.first() + Timber.d("Using DEFAULT profile: ${fallback.name} (ID: ${fallback.id})") + fallback + } + Timber.d("Profile loaded successfully: ${targetProfile.name}") - val customProfile = if (profileId != 0) manager?.getProfile(profileId) else null + // Load controllers for this profile + val controllers = targetProfile.loadControllers() + Timber.d("Controllers loaded: ${controllers.size} controller(s)") + controllers.forEachIndexed { index, controller -> + Timber.d(" [$index] ID: ${controller.id}, Name: ${controller.name}, Bindings: ${controller.controllerBindingCount}") + } - val targetProfile = if (customProfile != null) { - // Use the custom profile associated with this container - Timber.d("Using CUSTOM profile: ${customProfile.name} (ID: ${customProfile.id})") - customProfile - } else { - // Use Profile 0 (Physical Controller Default) as fallback - val fallback = manager?.getProfile(0) ?: profiles.getOrNull(2) ?: profiles.first() - Timber.d("Using DEFAULT profile: ${fallback.name} (ID: ${fallback.id})") - fallback - } - Timber.d("Profile loaded successfully: ${targetProfile.name}") + Timber.d("=== Profile Loading Complete ===") + setProfile(targetProfile) - // Load controllers for this profile - val controllers = targetProfile.loadControllers() - Timber.d("Controllers loaded: ${controllers.size} controller(s)") - controllers.forEachIndexed { index, controller -> - Timber.d(" [$index] ID: ${controller.id}, Name: ${controller.name}, Bindings: ${controller.controllerBindingCount}") + // Store profile for auto-show logic + loadedProfile = targetProfile } - Timber.d("=== Profile Loading Complete ===") - setProfile(targetProfile) - - // Store profile for auto-show logic - loadedProfile = targetProfile + // Set overlay opacity from preferences if needed + val opacity = PrefManager.getFloat("controls_opacity", InputControlsView.DEFAULT_OVERLAY_OPACITY) + setOverlayOpacity(opacity) } + PluviaApp.inputControlsView = icView - // Set overlay opacity from preferences if needed - val opacity = PrefManager.getFloat("controls_opacity", InputControlsView.DEFAULT_OVERLAY_OPACITY) - setOverlayOpacity(opacity) - } - PluviaApp.inputControlsView = icView + xServerView.getxServer().winHandler.setInputControlsView(PluviaApp.inputControlsView) - xServerView.getxServer().winHandler.setInputControlsView(PluviaApp.inputControlsView) + // Add InputControlsView on top of XServerView + frameLayout.addView(icView) + // Don't call hideInputControls() here - let the auto-show logic below handle visibility + // so that the view gets measured/laid out and has valid dimensions for element loading - - - // Add InputControlsView on top of XServerView - frameLayout.addView(icView) - // Don't call hideInputControls() here - let the auto-show logic below handle visibility - // so that the view gets measured/laid out and has valid dimensions for element loading - - // Auto-show on-screen controls after the view has been laid out and has proper dimensions - icView.post { - Timber.d("Auto-show logic running - view dimensions: ${icView.width}x${icView.height}") - loadedProfile?.let { profile -> - // Load elements if not already loaded (view has dimensions now) - if (!profile.isElementsLoaded) { - Timber.d("Loading profile elements for auto-show") - profile.loadElements(icView) - } - - // Only auto-show if profile has on-screen elements - Timber.d("Profile has ${profile.elements.size} elements loaded") - if (profile.elements.isNotEmpty()) { - // Check for ACTUAL physically connected controllers, not just saved bindings - val controllerManager = ControllerManager.getInstance() - controllerManager.scanForDevices() - val hasPhysicalController = controllerManager.getDetectedDevices().isNotEmpty() - - // Determine if controls should be shown based on priority: - // 1. If touchscreen mode is true → always hide - // 2. Else if physical controller detected → hide - // 3. Else → show - val shouldShowControls = when { - container.isTouchscreenMode -> false - hasPhysicalController -> false - else -> true + // Auto-show on-screen controls after the view has been laid out and has proper dimensions + icView.post { + Timber.d("Auto-show logic running - view dimensions: ${icView.width}x${icView.height}") + loadedProfile?.let { profile -> + // Load elements if not already loaded (view has dimensions now) + if (!profile.isElementsLoaded) { + Timber.d("Loading profile elements for auto-show") + profile.loadElements(icView) } - if (shouldShowControls) { - Timber.d("Auto-showing onscreen controls") - showInputControls(profile, xServerView.getxServer().winHandler, container) - areControlsVisible = true + // Only auto-show if profile has on-screen elements + Timber.d("Profile has ${profile.elements.size} elements loaded") + if (profile.elements.isNotEmpty()) { + // Check for ACTUAL physically connected controllers, not just saved bindings + val controllerManager = ControllerManager.getInstance() + controllerManager.scanForDevices() + val hasPhysicalController = controllerManager.getDetectedDevices().isNotEmpty() + + // Determine if controls should be shown based on priority: + // 1. If touchscreen mode is true → always hide + // 2. Else if physical controller detected → hide + // 3. Else → show + val shouldShowControls = when { + container.isTouchscreenMode -> false + hasPhysicalController -> false + else -> true + } + + if (shouldShowControls) { + Timber.d("Auto-showing onscreen controls") + showInputControls(profile, xServerView.getxServer().winHandler, container) + viewModel.setControlsVisible(true) + } else { + Timber.d("Hiding onscreen controls") + hideInputControls() + viewModel.setControlsVisible(false) + } } else { - Timber.d("Hiding onscreen controls") - hideInputControls() - areControlsVisible = false + Timber.w("Profile has no elements - cannot auto-show controls") } - } else { - Timber.w("Profile has no elements - cannot auto-show controls") } } - } - frameRating = FrameRating(context) - frameRating?.setVisibility(View.GONE) + frameRating = FrameRating(context) + frameRating?.setVisibility(View.GONE) - if (container.isShowFPS()) { - Timber.i("Attempting to show FPS") - frameRating?.let { frameLayout.addView(it) } - } + if (container.isShowFPS()) { + Timber.i("Attempting to show FPS") + frameRating?.let { frameLayout.addView(it) } + } - if (container.isDisableMouseInput){ - PluviaApp.touchpadView?.setTouchscreenMouseDisabled(true); - } + if (container.isDisableMouseInput) { + PluviaApp.touchpadView?.setTouchscreenMouseDisabled(true) + } - frameLayout + frameLayout - // } else { - // Log.d("XServerScreen", "Creating XServerView without creating XServer") - // xServerView = XServerView(context, PluviaApp.xServer) - // } - // xServerView - }, - update = { view -> - // View's been inflated or state read in this block has been updated - // Add logic here if necessary - // view.requestFocus() - }, - onRelease = { view -> - // view.releasePointerCapture() - // pointerEventListener?.let { - // view.removePointerEventListener(pointerEventListener) - // view.onRelease() - // } - }, - ) + // } else { + // Log.d("XServerScreen", "Creating XServerView without creating XServer") + // xServerView = XServerView(context, PluviaApp.xServer) + // } + // xServerView + }, + update = { view -> + // View's been inflated or state read in this block has been updated + // Add logic here if necessary + // view.requestFocus() + }, + onRelease = { view -> + // view.releasePointerCapture() + // pointerEventListener?.let { + // view.removePointerEventListener(pointerEventListener) + // view.onRelease() + // } + }, + ) // Floating toolbar for edit mode (always visible in edit mode) if (isEditMode && areControlsVisible) { @@ -883,8 +878,8 @@ fun XServerScreen( onEdit = { val selectedElement = PluviaApp.inputControlsView?.getSelectedElement() if (selectedElement != null) { - elementToEdit = selectedElement - showElementEditor = true + viewModel.setElementToEdit(selectedElement) + viewModel.setShowElementEditor(true) } }, onDelete = { @@ -893,10 +888,8 @@ fun XServerScreen( onSave = { // Save profile changes PluviaApp.inputControlsView?.profile?.save() - // Clear snapshot since changes were accepted - elementPositionsSnapshot = emptyMap() - // Exit edit mode - isEditMode = false + // Exit edit mode (saves changes) + viewModel.exitEditMode(saveChanges = true) PluviaApp.inputControlsView?.setEditMode(false) // Force redraw on next frame to ensure grid is removed PluviaApp.inputControlsView?.post { @@ -904,17 +897,8 @@ fun XServerScreen( } }, onClose = { - // Restore element positions from snapshot (cancel behavior) - if (elementPositionsSnapshot.isNotEmpty()) { - elementPositionsSnapshot.forEach { (element, position) -> - element.setX(position.first) - element.setY(position.second) - } - elementPositionsSnapshot = emptyMap() - } - - // Exit edit mode without saving - isEditMode = false + // Exit edit mode without saving (restores positions from snapshot) + viewModel.exitEditMode(saveChanges = false) PluviaApp.inputControlsView?.setEditMode(false) // Force redraw on next frame to ensure grid is removed PluviaApp.inputControlsView?.post { @@ -959,7 +943,7 @@ fun XServerScreen( } } } - } + }, ) } } @@ -970,13 +954,13 @@ fun XServerScreen( element = elementToEdit!!, view = PluviaApp.inputControlsView!!, onDismiss = { - showElementEditor = false + viewModel.setShowElementEditor(false) // Keep edit mode active so user can edit other elements }, onSave = { - showElementEditor = false + viewModel.setShowElementEditor(false) // Keep edit mode active so user can edit other elements - } + }, ) } @@ -995,16 +979,16 @@ fun XServerScreen( if (profile != null) { androidx.compose.ui.window.Dialog( - onDismissRequest = { showPhysicalControllerDialog = false } + onDismissRequest = { viewModel.setShowPhysicalControllerDialog(false) }, ) { androidx.compose.foundation.layout.Box( modifier = Modifier .fillMaxSize() - .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.95f)) + .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.95f)), ) { app.gamenative.ui.component.dialog.PhysicalControllerConfigSection( profile = profile, - onDismiss = { showPhysicalControllerDialog = false }, + onDismiss = { viewModel.setShowPhysicalControllerDialog(false) }, onSave = { profile.save() profile.loadControllers() @@ -1013,22 +997,21 @@ fun XServerScreen( if (PluviaApp.inputControlsView?.profile != null) { PluviaApp.inputControlsView?.setProfile(profile) } - showPhysicalControllerDialog = false - } + viewModel.setShowPhysicalControllerDialog(false) + }, ) } } } } - // var ranSetup by rememberSaveable { mutableStateOf(false) } - // LaunchedEffect(lifecycleOwner) { - // if (!ranSetup) { - // ranSetup = true - // - // - // } - // } + // Quick Menu overlay + QuickMenu( + isVisible = showQuickMenu, + onDismiss = { viewModel.setShowQuickMenu(false) }, + onItemSelected = onQuickMenuItemSelected, + hasPhysicalController = hasPhysicalController, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -1039,7 +1022,7 @@ private fun EditModeToolbar( onDelete: () -> Unit, onSave: () -> Unit, onClose: () -> Unit, - onDuplicate: (Int) -> Unit + onDuplicate: (Int) -> Unit, ) { var duplicateProfileOpen by remember { mutableStateOf(false) } var toolbarOffsetX by remember { mutableStateOf(0f) } @@ -1059,25 +1042,25 @@ private fun EditModeToolbar( toolbarOffsetX += dragAmount.x / density.density toolbarOffsetY += dragAmount.y / density.density } - } + }, ) { Row( modifier = Modifier .wrapContentSize() .background( color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.8f), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), ) .padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { // Drag handle indicator Icon( imageVector = Icons.Default.Menu, contentDescription = "Drag to move", tint = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.7f), - modifier = Modifier.padding(end = 4.dp) + modifier = Modifier.padding(end = 4.dp), ) // Add button @@ -1113,7 +1096,7 @@ private fun EditModeToolbar( if (knownProfiles.isNotEmpty()) { DropdownMenu( expanded = duplicateProfileOpen, - onDismissRequest = { duplicateProfileOpen = false } + onDismissRequest = { duplicateProfileOpen = false }, ) { for (knownProfile in knownProfiles) { DropdownMenuItem( @@ -1187,20 +1170,16 @@ private fun showInputControls(profile: ControlsProfile, winHandler: WinHandler, PluviaApp.touchpadView?.setSensitivity(profile.getCursorSpeed() * 1.0f) PluviaApp.touchpadView?.setPointerButtonRightEnabled(false) - // If the selected profile is a virtual gamepad, we must enable the P1 slot. if (container.containerVariant.equals(Container.BIONIC) && profile.isVirtualGamepad()) { val controllerManager: ControllerManager = ControllerManager.getInstance() - // Ensure Player 1 slot is enabled so a vjoy device is created for it. controllerManager.setSlotEnabled(0, true) - // Clear any physical device from P1 to prevent conflicts. controllerManager.unassignSlot(0) - // Tell WinHandler to update its internal state. if (winHandler != null) { winHandler.refreshControllerMappings() @@ -1309,7 +1288,7 @@ private fun assignTaskAffinity( val processAffinity = if (window.isWoW64()) taskAffinityMaskWoW64 else taskAffinityMask if (className.equals("steam.exe")) { - return; + return } if (processId > 0) { winHandler.setProcessAffinity(processId, processAffinity) @@ -1376,8 +1355,8 @@ private fun setupXEnvironment( context: Context, appId: String, bootToContainer: Boolean, - xServerState: MutableState, - // xServerViewModel: XServerViewModel, + xServerState: XServerState, + viewModel: XServerViewModel, envVars: EnvVars, // generateWinePrefix: Boolean, container: Container?, @@ -1402,13 +1381,13 @@ private fun setupXEnvironment( envVars.put("MESA_DEBUG", "silent") envVars.put("MESA_NO_ERROR", "1") envVars.put("WINEPREFIX", imageFs.wineprefix) - if (container.isShowFPS){ + if (container.isShowFPS) { envVars.put("DXVK_HUD", "fps,frametimes") envVars.put("VK_INSTANCE_LAYERS", "VK_LAYER_MESA_overlay") envVars.put("MESA_OVERLAY_SHOW_FPS", 1) } - if (container.isSdlControllerAPI){ - if (container.inputType == PreferredInputApi.XINPUT.ordinal || container.inputType == PreferredInputApi.AUTO.ordinal){ + if (container.isSdlControllerAPI) { + if (container.inputType == PreferredInputApi.XINPUT.ordinal || container.inputType == PreferredInputApi.AUTO.ordinal) { envVars.put("SDL_XINPUT_ENABLED", "1") envVars.put("SDL_DIRECTINPUT_ENABLED", "0") envVars.put("SDL_JOYSTICK_HIDAPI", "1") @@ -1437,10 +1416,11 @@ private fun setupXEnvironment( // explicitly enable or disable Wine debug channels envVars.put( "WINEDEBUG", - if (enableWineDebug && wineDebugChannels.isNotEmpty()) + if (enableWineDebug && wineDebugChannels.isNotEmpty()) { "+" + wineDebugChannels.replace(",", ",+") - else - "-all", + } else { + "-all" + }, ) // capture debug output to file if either Wine or Box86/64 logging is enabled var logFile: File? = null @@ -1468,8 +1448,7 @@ private fun setupXEnvironment( contentsManager, contentsManager.getProfileByEntryName(container.wineVersion), ) - } - else { + } else { Timber.i("Setting guestProgramLauncherComponent to BionicProgramLauncherComponent") BionicProgramLauncherComponent( contentsManager, @@ -1479,19 +1458,19 @@ private fun setupXEnvironment( if (container != null) { if (container.startupSelection == Container.STARTUP_SELECTION_AGGRESSIVE) { - if (container.containerVariant.equals(Container.BIONIC)){ + if (container.containerVariant.equals(Container.BIONIC)) { Timber.d("Incorrect startup selection detected. Reverting to essential startup selection") container.startupSelection = Container.STARTUP_SELECTION_ESSENTIAL container.putExtra("startupSelection", java.lang.String.valueOf(Container.STARTUP_SELECTION_ESSENTIAL)) container.saveData() } else { - xServer.winHandler.killProcess("services.exe"); + xServer.winHandler.killProcess("services.exe") } } val wow64Mode = container.isWoW64Mode - guestProgramLauncherComponent.setContainer(container); - guestProgramLauncherComponent.setWineInfo(xServerState.value.wineInfo); + guestProgramLauncherComponent.setContainer(container) + guestProgramLauncherComponent.setWineInfo(xServerState.wineInfo) val guestExecutable = "wine explorer /desktop=shell," + xServer.screenInfo + " " + getWineStartCommand(appId, container, bootToContainer, appLaunchInfo, envVars, guestProgramLauncherComponent) + (if (container.execArgs.isNotEmpty()) " " + container.execArgs else "") @@ -1548,27 +1527,29 @@ private fun setupXEnvironment( // environment.addComponent(SteamClientComponent(UnixSocketConfig.createSocket(SteamService.getAppDirPath(appId), "/steam_pipe"))) // environment.addComponent(SteamClientComponent(UnixSocketConfig.createSocket(rootPath, UnixSocketConfig.STEAM_PIPE_PATH))) - if (xServerState.value.audioDriver == "alsa") { + if (xServerState.audioDriver == "alsa") { envVars.put("ANDROID_ALSA_SERVER", imageFs.getRootDir().getPath() + UnixSocketConfig.ALSA_SERVER_PATH) envVars.put("ANDROID_ASERVER_USE_SHM", "true") val options = ALSAClient.Options.fromKeyValueSet(null) environment.addComponent(ALSAServerComponent(UnixSocketConfig.createSocket(imageFs.getRootDir().getPath(), UnixSocketConfig.ALSA_SERVER_PATH), options)) - } else if (xServerState.value.audioDriver == "pulseaudio") { + } else if (xServerState.audioDriver == "pulseaudio") { envVars.put("PULSE_SERVER", imageFs.getRootDir().getPath() + UnixSocketConfig.PULSE_SERVER_PATH) environment.addComponent(PulseAudioComponent(UnixSocketConfig.createSocket(imageFs.getRootDir().getPath(), UnixSocketConfig.PULSE_SERVER_PATH))) } - if (xServerState.value.graphicsDriver == "virgl") { + if (xServerState.graphicsDriver == "virgl") { environment.addComponent( VirGLRendererComponent( xServer, UnixSocketConfig.createSocket(rootPath, UnixSocketConfig.VIRGL_SERVER_PATH), ), ) - } else if (xServerState.value.graphicsDriver == "vortek" || xServerState.value.graphicsDriver == "adreno" || xServerState.value.graphicsDriver == "sd-8-elite") { + } else if (xServerState.graphicsDriver == "vortek" || xServerState.graphicsDriver == "adreno" || + xServerState.graphicsDriver == "sd-8-elite" + ) { Timber.i("Adding VortekRendererComponent to Environment") val gcfg = KeyValueSet(container.getGraphicsDriverConfig()) - val graphicsDriver = xServerState.value.graphicsDriver + val graphicsDriver = xServerState.graphicsDriver if (graphicsDriver == "sd-8-elite" || graphicsDriver == "adreno") { gcfg.put("adrenotoolsDriver", "vulkan.adreno.so") container.setGraphicsDriverConfig(gcfg.toString()) @@ -1605,7 +1586,7 @@ private fun setupXEnvironment( Timber.i("CPU List: ${container.cpuList}") Timber.i("CPU List WoW64: ${container.cpuListWoW64}") Timber.i("Env Vars (Container Base): ${container.envVars}") // Log base container vars - Timber.i("Env Vars (Final Guest): ${envVars.toString()}") // Log the actual env vars being passed + Timber.i("Env Vars (Final Guest): $envVars") // Log the actual env vars being passed Timber.i("Guest Executable: ${guestProgramLauncherComponent.guestExecutable}") // Log the command Timber.i("---------------------------") } @@ -1635,9 +1616,7 @@ private fun setupXEnvironment( xServer.winHandler.start() } envVars.clear() - xServerState.value = xServerState.value.copy( - dxwrapperConfig = null, - ) + viewModel.setDxwrapperConfig(null) return environment } private fun getWineStartCommand( @@ -1658,11 +1637,11 @@ private fun getWineStartCommand( val steamAppId = ContainerUtils.extractGameIdFromContainerId(appId) if (!isCustomGame) { - if (container.executablePath.isEmpty()){ + if (container.executablePath.isEmpty()) { container.executablePath = SteamService.getInstalledExe(steamAppId) container.saveData() } - if (!container.isUseLegacyDRM){ + if (!container.isUseLegacyDRM) { // Create ColdClientLoader.ini file SteamUtils.writeColdClientIni(steamAppId, container) } @@ -1724,7 +1703,7 @@ private fun getWineStartCommand( if (container.isLaunchRealSteam()) { // Launch Steam with the applaunch parameter to start the game "\"C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe\" -silent -vgui -tcp " + - "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $steamAppId" + "-nobigpicture -nofriendsui -nochatui -nointro -applaunch $steamAppId" } else { var executablePath = "" if (container.executablePath.isNotEmpty()) { @@ -1737,8 +1716,8 @@ private fun getWineStartCommand( if (container.isUseLegacyDRM) { val appDirPath = SteamService.getAppDirPath(steamAppId) val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "") - guestProgramLauncherComponent.workingDir = File(executableDir); - Timber.i("Working directory is ${executableDir}") + guestProgramLauncherComponent.workingDir = File(executableDir) + Timber.i("Working directory is $executableDir") Timber.i("Final exe path is " + executablePath) val drives = container.drives @@ -1777,9 +1756,17 @@ private fun getSteamlessTarget( Timber.e("Could not locate game drive") 'D' } - return "$drive:\\${executablePath}" + return "$drive:\\$executablePath" } -private fun exit(winHandler: WinHandler?, environment: XEnvironment?, frameRating: FrameRating?, appInfo: SteamApp?, container: Container, onExit: () -> Unit, navigateBack: () -> Unit) { +private fun exit( + winHandler: WinHandler?, + environment: XEnvironment?, + frameRating: FrameRating?, + appInfo: SteamApp?, + container: Container, + onExit: () -> Unit, + navigateBack: () -> Unit, +) { Timber.i("Exit called") PostHog.capture( event = "game_exited", @@ -1887,8 +1874,10 @@ private fun installRedistributables( val physxDir = File(commonRedistDir, "PhysX") if (physxDir.exists() && physxDir.isDirectory()) { physxDir.walkTopDown() - .filter { it.isFile && it.name.startsWith("PhysX", ignoreCase = true) && - it.name.endsWith(".msi", ignoreCase = true) } + .filter { + it.isFile && it.name.startsWith("PhysX", ignoreCase = true) && + it.name.endsWith(".msi", ignoreCase = true) + } .forEach { msiFile -> try { val relativePath = msiFile.relativeTo(commonRedistDir).path.replace('/', '\\') @@ -1908,8 +1897,10 @@ private fun installRedistributables( val xnaDir = File(commonRedistDir, "xnafx") if (xnaDir.exists() && xnaDir.isDirectory()) { xnaDir.walkTopDown() - .filter { it.isFile && it.name.startsWith("xna", ignoreCase = true) && - it.name.endsWith(".msi", ignoreCase = true) } + .filter { + it.isFile && it.name.startsWith("xna", ignoreCase = true) && + it.name.endsWith(".msi", ignoreCase = true) + } .forEach { msiFile -> try { val relativePath = msiFile.relativeTo(commonRedistDir).path.replace('/', '\\') @@ -1943,7 +1934,7 @@ private fun unpackExecutableFile( ) { val imageFs = ImageFs.find(context) var output = StringBuilder() - if (needsUnpacking || containerVariantChanged){ + if (needsUnpacking || containerVariantChanged) { try { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing Mono...")) val monoCmd = "wine msiexec /i Z:\\opt\\mono-gecko-offline\\wine-mono-9.0.0-x86.msi && wineserver -k" @@ -1962,7 +1953,7 @@ private fun unpackExecutableFile( Timber.e(e, "Error installing redistributables: ${e.message}") } } - if (!needsUnpacking){ + if (!needsUnpacking) { return } try { @@ -1972,7 +1963,7 @@ private fun unpackExecutableFile( try { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM...")) // a:/.../GameDir/orig_dll_path.txt (same dir as the EXE inside A:) - val origTxtFile = File("${imageFs.wineprefix}/dosdevices/a:/orig_dll_path.txt") + val origTxtFile = File("${imageFs.wineprefix}/dosdevices/a:/orig_dll_path.txt") if (origTxtFile.exists()) { val relDllPath = origTxtFile.readText().trim() @@ -2093,15 +2084,17 @@ private fun extractx86_64InputDlls(context: Context, container: Container) { if ("proton-9.0-x86_64" == wineVersion) { val wineFolder: File = File(imageFs.getWinePath() + "/lib/wine/") Log.d("XServerDisplayActivity", "Extracting input dlls to " + wineFolder.getPath()) - } else Log.d("XServerDisplayActivity", "Wine version is not proton-9.0-x86_64, skipping input dlls extraction") + } else { + Log.d("XServerDisplayActivity", "Wine version is not proton-9.0-x86_64, skipping input dlls extraction") + } } private fun setupWineSystemFiles( context: Context, firstTimeBoot: Boolean, screenInfo: ScreenInfo, - xServerState: MutableState, - // xServerViewModel: XServerViewModel, + xServerState: XServerState, + viewModel: XServerViewModel, container: Container, containerManager: ContainerManager, // shortcut: Shortcut?, @@ -2117,31 +2110,28 @@ private fun setupWineSystemFiles( var containerDataChanged = false if (!container.getExtra("appVersion").equals(appVersion) || !container.getExtra("imgVersion").equals(imgVersion) || - container.containerVariant != variant || (container.containerVariant == variant && container.wineVersion != wineVersion)) { - applyGeneralPatches(context, container, imageFs, xServerState.value.wineInfo, containerManager, onExtractFileListener) + container.containerVariant != variant || (container.containerVariant == variant && container.wineVersion != wineVersion) + ) { + applyGeneralPatches(context, container, imageFs, xServerState.wineInfo, containerManager, onExtractFileListener) container.putExtra("appVersion", appVersion) container.putExtra("imgVersion", imgVersion) containerDataChanged = true } // Normalize dxwrapper for state (dxvk includes version for extraction switch) - if (xServerState.value.dxwrapper == "dxvk") { - xServerState.value = xServerState.value.copy( - dxwrapper = "dxvk-" + xServerState.value.dxwrapperConfig?.get("version"), - ) + if (xServerState.dxwrapper == "dxvk") { + viewModel.setDxwrapper("dxvk-" + xServerState.dxwrapperConfig?.get("version")) } // Also normalize VKD3D to include version like vkd3d- - if (xServerState.value.dxwrapper == "vkd3d") { - xServerState.value = xServerState.value.copy( - dxwrapper = "vkd3d-" + xServerState.value.dxwrapperConfig?.get("vkd3dVersion"), - ) + if (xServerState.dxwrapper == "vkd3d") { + viewModel.setDxwrapper("vkd3d-" + xServerState.dxwrapperConfig?.get("vkd3dVersion")) } - val needReextract = xServerState.value.dxwrapper != container.getExtra("dxwrapper") || container.wineVersion != wineVersion + val needReextract = xServerState.dxwrapper != container.getExtra("dxwrapper") || container.wineVersion != wineVersion Timber.i("needReextract is " + needReextract) - Timber.i("xServerState.value.dxwrapper is " + xServerState.value.dxwrapper) + Timber.i("xServerState.dxwrapper is " + xServerState.dxwrapper) Timber.i("container.getExtra(\"dxwrapper\") is " + container.getExtra("dxwrapper")) if (needReextract) { @@ -2150,16 +2140,16 @@ private fun setupWineSystemFiles( firstTimeBoot, container, containerManager, - xServerState.value.dxwrapper, + xServerState.dxwrapper, imageFs, contentsManager, onExtractFileListener, ) - container.putExtra("dxwrapper", xServerState.value.dxwrapper) + container.putExtra("dxwrapper", xServerState.dxwrapper) containerDataChanged = true } - if (xServerState.value.dxwrapper == "cnc-ddraw") envVars.put("CNC_DDRAW_CONFIG_FILE", "C:\\ProgramData\\cnc-ddraw\\ddraw.ini") + if (xServerState.dxwrapper == "cnc-ddraw") envVars.put("CNC_DDRAW_CONFIG_FILE", "C:\\ProgramData\\cnc-ddraw\\ddraw.ini") // val wincomponents = if (shortcut != null) shortcut.getExtra("wincomponents", container.winComponents) else container.winComponents val wincomponents = container.winComponents @@ -2169,7 +2159,7 @@ private fun setupWineSystemFiles( containerDataChanged = true } - if (container.isLaunchRealSteam){ + if (container.isLaunchRealSteam) { extractSteamFiles(context, container, onExtractFileListener) } @@ -2216,17 +2206,17 @@ private fun applyGeneralPatches( rootDir, onExtractFileListener, ) - } else if (downloaded.exists()){ + } else if (downloaded.exists()) { TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, downloaded, rootDir, onExtractFileListener, - ); + ) } } else { Timber.i("Extracting container_pattern_common.tzst") - TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, context.assets, "container_pattern_common.tzst", rootDir); + TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, context.assets, "container_pattern_common.tzst", rootDir) Timber.i("Attempting to extract _container_pattern.tzst with wine version " + container.wineVersion) } containerManager.extractContainerPatternFile(container.getWineVersion(), contentsManager, container.rootDir, null) @@ -2268,6 +2258,7 @@ private fun extractDXWrapperFiles( "wined3d" -> { restoreOriginalDllFiles(context, container, containerManager, imageFs, *dlls) } + "cnc-ddraw" -> { restoreOriginalDllFiles(context, container, containerManager, imageFs, *dlls) val assetDir = "dxwrapper/cnc-ddraw-" + DefaultVersion.CNC_DDRAW @@ -2281,20 +2272,28 @@ private fun extractDXWrapperFiles( "$assetDir/ddraw.tzst", windowsDir, onExtractFileListener, ) } + "vkd3d" -> { Timber.i("Extracting VKD3D D3D12 DLLs for dxwrapper: $dxwrapper") val profile: ContentProfile? = contentsManager.getProfileByEntryName(dxwrapper) // Determine graphics driver to choose DXVK version - val vortekLike = container.graphicsDriver == "vortek" || container.graphicsDriver == "adreno" || container.graphicsDriver == "sd-8-elite" - val dxvkVersionForVkd3d = if (vortekLike && GPUHelper.vkGetApiVersionSafe() < GPUHelper.vkMakeVersion(1, 3, 0)) "1.10.3" else "2.4.1" + val vortekLike = + container.graphicsDriver == "vortek" || container.graphicsDriver == "adreno" || container.graphicsDriver == "sd-8-elite" + val dxvkVersionForVkd3d = if (vortekLike && + GPUHelper.vkGetApiVersionSafe() < GPUHelper.vkMakeVersion(1, 3, 0) + ) { + "1.10.3" + } else { + "2.4.1" + } Timber.i("Extracting VKD3D DX version for dxwrapper: $dxvkVersionForVkd3d") TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "dxwrapper/dxvk-${dxvkVersionForVkd3d}.tzst", windowsDir, onExtractFileListener, + "dxwrapper/dxvk-$dxvkVersionForVkd3d.tzst", windowsDir, onExtractFileListener, ) if (profile != null) { Timber.d("Applying user-defined VKD3D content profile: " + dxwrapper) - contentsManager.applyContent(profile); + contentsManager.applyContent(profile) } else { // Determine VKD3D version from state config Timber.i("Extracting VKD3D D3D12 DLLs version: $dxwrapper") @@ -2308,6 +2307,7 @@ private fun extractDXWrapperFiles( ) } } + else -> { val profile: ContentProfile? = contentsManager.getProfileByEntryName(dxwrapper) // This block handles dxvk-VERSION strings @@ -2315,7 +2315,7 @@ private fun extractDXWrapperFiles( restoreOriginalDllFiles(context, container, containerManager, imageFs, "d3d12.dll", "d3d12core.dll", "ddraw.dll") if (profile != null) { Timber.d("Applying user-defined DXVK content profile: " + dxwrapper) - contentsManager.applyContent(profile); + contentsManager.applyContent(profile) } else { TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, @@ -2396,12 +2396,14 @@ private fun restoreOriginalDllFiles( var system32dlls: File? = null var syswow64dlls: File? = null - if (container.wineVersion.contains("arm64ec")) system32dlls = File(imageFs.getWinePath() + "/lib/wine/aarch64-windows") - else system32dlls = File(imageFs.getWinePath() + "/lib/wine/x86_64-windows") + if (container.wineVersion.contains("arm64ec")) { + system32dlls = File(imageFs.getWinePath() + "/lib/wine/aarch64-windows") + } else { + system32dlls = File(imageFs.getWinePath() + "/lib/wine/x86_64-windows") + } syswow64dlls = File(imageFs.getWinePath() + "/lib/wine/i386-windows") - for (dll in dlls) { var srcFile = File(system32dlls, dll) var dstFile = File(windowsDir, "system32/" + dll) @@ -2505,9 +2507,9 @@ private fun extractGraphicsDriverFiles( cacheId += "-" + turnipVersion + "-" + zinkVersion if (turnipVersion == "25.2.0" || turnipVersion == "25.3.0") { if (GPUInformation.isAdreno710_720_732(context)) { - envVars.put("TU_DEBUG", "gmem"); + envVars.put("TU_DEBUG", "gmem") } else { - envVars.put("TU_DEBUG", "sysmem"); + envVars.put("TU_DEBUG", "sysmem") } } } else if (graphicsDriver == "virgl") { @@ -2518,7 +2520,7 @@ private fun extractGraphicsDriverFiles( val imageFs = ImageFs.find(context) val configDir = imageFs.configDir - val sentinel = File(configDir, ".current_graphics_driver") // lives in shared tree + val sentinel = File(configDir, ".current_graphics_driver") // lives in shared tree val onDiskId = sentinel.takeIf { it.exists() }?.readText() ?: "" val changed = cacheId != container.getExtra("graphicsDriver") || cacheId != onDiskId Timber.i("Changed is " + changed + " will re-extract drivers accordingly.") @@ -2566,13 +2568,13 @@ private fun extractGraphicsDriverFiles( TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "graphics_driver/turnip-${turnipVersion}.tzst", + "graphics_driver/turnip-$turnipVersion.tzst", rootDir, ) TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "graphics_driver/zink-${zinkVersion}.tzst", + "graphics_driver/zink-$zinkVersion.tzst", rootDir, ) } @@ -2586,7 +2588,7 @@ private fun extractGraphicsDriverFiles( if (changed) { TarCompressorUtils.extract( TarCompressorUtils.Type.ZSTD, context.assets, - "graphics_driver/virgl-${virglVersion}.tzst", rootDir, + "graphics_driver/virgl-$virglVersion.tzst", rootDir, ) } } else if (graphicsDriver == "vortek") { @@ -2605,7 +2607,7 @@ private fun extractGraphicsDriverFiles( TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, context.assets, "graphics_driver/zink-22.2.5.tzst", rootDir) } } else if (graphicsDriver == "adreno" || graphicsDriver == "sd-8-elite") { - val assetZip = if (graphicsDriver == "adreno") "Adreno_${adrenoVersion}_adpkg.zip" else "SD8Elite_${sd8EliteVersion}.zip" + val assetZip = if (graphicsDriver == "adreno") "Adreno_${adrenoVersion}_adpkg.zip" else "SD8Elite_$sd8EliteVersion.zip" val componentRoot = com.winlator.core.GeneralComponents.getComponentDir( com.winlator.core.GeneralComponents.Type.ADRENOTOOLS_DRIVER, @@ -2616,7 +2618,7 @@ private fun extractGraphicsDriverFiles( val identifier = readZipManifestNameFromAssets(context, assetZip) ?: assetZip.substringBeforeLast('.') // Only (re)extract if changed - val adrenoCacheId = "${graphicsDriver}-${identifier}" + val adrenoCacheId = "$graphicsDriver-$identifier" val needsExtract = changed || adrenoCacheId != container.getExtra("graphicsDriverAdreno") if (needsExtract) { @@ -2670,7 +2672,6 @@ private fun extractGraphicsDriverFiles( DXVKHelper.setVKD3DEnvVars(context, dxwrapperConfig, envVars) } - val useDRI3: Boolean = container.isUseDRI3 if (!useDRI3) { envVars.put("MESA_VK_WSI_DEBUG", "sw") @@ -2678,12 +2679,14 @@ private fun extractGraphicsDriverFiles( if (currentWrapperVersion.lowercase(Locale.getDefault()) .contains("turnip") && isAdrenotoolsTurnip == "0" - ) envVars.put("VK_ICD_FILENAMES", imageFs.getShareDir().path + "/vulkan/icd.d/freedreno_icd.aarch64.json") - else envVars.put("VK_ICD_FILENAMES", imageFs.getShareDir().path + "/vulkan/icd.d/wrapper_icd.aarch64.json") + ) { + envVars.put("VK_ICD_FILENAMES", imageFs.getShareDir().path + "/vulkan/icd.d/freedreno_icd.aarch64.json") + } else { + envVars.put("VK_ICD_FILENAMES", imageFs.getShareDir().path + "/vulkan/icd.d/wrapper_icd.aarch64.json") + } envVars.put("GALLIUM_DRIVER", "zink") envVars.put("LIBGL_KOPPER_DISABLE", "true") - // if (firstTimeBoot) { // Log.d("XServerDisplayActivity", "First time container boot, re-extracting wrapper"); // TarCompressorUtils.extract(TarCompressorUtils.Type.ZSTD, this, "graphics_driver/wrapper" + ".tzst", rootDir); @@ -2693,11 +2696,9 @@ private fun extractGraphicsDriverFiles( // 1. Get the main WRAPPER selection (e.g., "Wrapper-v2") from the class field. val mainWrapperSelection: String = graphicsDriver - // 2. Get the WRAPPER that was last saved to the container's settings. val lastInstalledMainWrapper = container.getExtra("lastInstalledMainWrapper") - // 3. Check if we need to extract a new wrapper file. if (firstTimeBoot || mainWrapperSelection != lastInstalledMainWrapper) { // We only extract if the selection is actually a wrapper file. @@ -2734,8 +2735,9 @@ private fun extractGraphicsDriverFiles( envVars.put("WRAPPER_EXTENSION_BLACKLIST", blacklistedExtensions) val maxDeviceMemory: String? = graphicsDriverConfig.get("maxDeviceMemory", "0") - if (maxDeviceMemory != null && maxDeviceMemory.toInt() > 0) + if (maxDeviceMemory != null && maxDeviceMemory.toInt() > 0) { envVars.put("WRAPPER_VMEM_MAX_SIZE", maxDeviceMemory) + } val presentMode = graphicsDriverConfig.get("presentMode") if (presentMode.contains("immediate")) { @@ -2784,11 +2786,11 @@ private fun extractSteamFiles( downloaded, imageFs.getRootDir(), onExtractFileListener, - ); + ) } private fun readZipManifestNameFromAssets(context: Context, assetName: String): String? { - return com.winlator.core.FileUtils.readZipManifestNameFromAssets(context, assetName) + return FileUtils.readZipManifestNameFromAssets(context, assetName) } private fun readLibraryNameFromExtractedDir(destinationDir: File): String? { @@ -2796,11 +2798,13 @@ private fun readLibraryNameFromExtractedDir(destinationDir: File): String? { val manifests = destinationDir.listFiles { _, name -> name.endsWith(".json") } if (manifests != null && manifests.isNotEmpty()) { val manifest = manifests[0] - val content = com.winlator.core.FileUtils.readString(manifest) - val json = org.json.JSONObject(content) + val content = FileUtils.readString(manifest) + val json = JSONObject(content) val libraryName = json.optString("libraryName", "").trim() - if (libraryName.isNotEmpty()) libraryName else null - } else null + libraryName.ifEmpty { null } + } else { + null + } } catch (_: Exception) { null } diff --git a/app/src/main/java/app/gamenative/ui/theme/Color.kt b/app/src/main/java/app/gamenative/ui/theme/Color.kt index 2f2e10078..c1404fe2f 100644 --- a/app/src/main/java/app/gamenative/ui/theme/Color.kt +++ b/app/src/main/java/app/gamenative/ui/theme/Color.kt @@ -1,53 +1,64 @@ package app.gamenative.ui.theme -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color -import com.alorma.compose.settings.ui.base.internal.SettingsTileColors -import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults -// Your custom color scheme -val customBackground = Color(0xFF09090B) -val customForeground = Color(0xFFFAFAFA) -val customCard = Color(0xFF09090B) -val customCardForeground = Color(0xFFFAFAFA) -val customPrimary = Color(0xFFA21CAF) -val customPrimaryForeground = Color(0xFFFAFAFA) -val customSecondary = Color(0xFF27272A) -val customSecondaryForeground = Color(0xFFFAFAFA) -val customMuted = Color(0xFF27272A) -val customMutedForeground = Color(0xFF94969C) -val customAccent = Color(0xFF06B6D4) -val customAccentForeground = Color(0xFFFAFAFA) -val customDestructive = Color(0xFF7F1D1D) - -val pluviaSeedColor = Color(0x284561FF) - -/* Friend Status Colors */ -val friendAwayOrSnooze = Color(0x806DCFF6) -val friendInGame = Color(0xFF90BA3C) -val friendInGameAwayOrSnooze = Color(0x8090BA3C) -val friendOffline = Color(0xFF7A7A7A) -val friendOnline = Color(0xFF6DCFF6) -val friendBlocked = Color(0xFF983D3D) /** - * Alorma compose settings tile colors + * Raw color primitives for the Pluvia app. + * These are the base colors used to construct theme palettes. */ -@Composable -fun settingsTileColors(): SettingsTileColors = SettingsTileDefaults.colors( - titleColor = customForeground, - subtitleColor = customMutedForeground, - actionColor = customAccent, -) - -@Composable -fun settingsTileColorsAlt(): SettingsTileColors = SettingsTileDefaults.colors( - titleColor = customForeground, - subtitleColor = customMutedForeground, -) - -@Composable -fun settingsTileColorsDebug(): SettingsTileColors = SettingsTileDefaults.colors( - titleColor = customDestructive, - subtitleColor = customMutedForeground, - actionColor = customAccent, -) + +// Brand +val PluviaPrimary = Color(0xFFA21CAF) +val PluviaSeed = Color(0x284561FF) + +// Backgrounds +val PluviaBackground = Color(0xFF09090B) +val PluviaSurface = Color(0xFF12121A) +val PluviaSurfaceElevated = Color(0xFF1A1A24) +val PluviaCard = Color(0xFF09090B) + +// Foregrounds +val PluviaForeground = Color(0xFFFAFAFA) +val PluviaForegroundMuted = Color(0xFF94969C) + +// Secondary +val PluviaSecondary = Color(0xFF27272A) + +// Accents +val PluviaCyan = Color(0xFF00D4FF) +val PluviaPurple = Color(0xFF8B5CF6) +val PluviaPink = Color(0xFFEC4899) + +// Semantic +val PluviaSuccess = Color(0xFF10B981) +val PluviaWarning = Color(0xFFF59E0B) +val PluviaDanger = Color(0xFFEF4444) +val PluviaDestructive = Color(0xFF7F1D1D) + +// Border +val PluviaBorder = Color(0xFF3A3A4A) + +// Status - Installed/Download states +val StatusInstalled = Color(0xFF4CAF50) +val StatusDownloading = Color(0xFF00BCD4) +val StatusAvailable = Color(0xFF2196F3) +val StatusAway = Color(0xFFFF9800) +val StatusOffline = Color(0xFF9E9E9E) + +// Friend states +val FriendOnline = Color(0xFF6DCFF6) +val FriendOffline = Color(0xFF7A7A7A) +val FriendInGame = Color(0xFF90BA3C) +val FriendAwayOrSnooze = Color(0x806DCFF6) +val FriendInGameAwayOrSnooze = Color(0x8090BA3C) +val FriendBlocked = Color(0xFF983D3D) + +// Compatibility +val CompatibilityGood = Color(0xFF4CAF50) +val CompatibilityGoodBg = Color(0xFF1B5E20) +val CompatibilityPartial = Color(0xFF8BC34A) +val CompatibilityPartialBg = Color(0xFF33691E) +val CompatibilityUnknown = Color(0xFF9E9E9E) +val CompatibilityUnknownBg = Color(0xFF424242) +val CompatibilityBad = Color(0xFFEF5350) +val CompatibilityBadBg = Color(0xFFB71C1C) diff --git a/app/src/main/java/app/gamenative/ui/theme/Theme.kt b/app/src/main/java/app/gamenative/ui/theme/Theme.kt index 1d3a63b09..efd92b606 100644 --- a/app/src/main/java/app/gamenative/ui/theme/Theme.kt +++ b/app/src/main/java/app/gamenative/ui/theme/Theme.kt @@ -4,84 +4,257 @@ import android.app.Activity import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import com.alorma.compose.settings.ui.base.internal.SettingsTileColors +import com.alorma.compose.settings.ui.base.internal.SettingsTileDefaults import com.materialkolor.PaletteStyle -// Custom dark color scheme based on your provided colors -private val CustomDarkColorScheme = darkColorScheme( - primary = customPrimary, - onPrimary = customPrimaryForeground, - primaryContainer = customPrimary.copy(alpha = 0.2f), - onPrimaryContainer = customPrimaryForeground, +/** + * Custom color system for Pluvia, extending Material3. + * Provides app-specific colors beyond the Material ColorScheme. + */ +@Immutable +data class PluviaColors( + // Status colors + val statusInstalled: Color, + val statusDownloading: Color, + val statusAvailable: Color, + val statusAway: Color, + val statusOffline: Color, - secondary = customSecondary, - onSecondary = customSecondaryForeground, - secondaryContainer = customSecondary.copy(alpha = 0.8f), - onSecondaryContainer = customSecondaryForeground, + // Friend status + val friendOnline: Color, + val friendOffline: Color, + val friendInGame: Color, + val friendAwayOrSnooze: Color, + val friendInGameAwayOrSnooze: Color, + val friendBlocked: Color, - tertiary = customAccent, - onTertiary = customAccentForeground, - tertiaryContainer = customAccent.copy(alpha = 0.2f), - onTertiaryContainer = customAccentForeground, + // Accents + val accentCyan: Color, + val accentPurple: Color, + val accentPink: Color, + val accentSuccess: Color, + val accentWarning: Color, + val accentDanger: Color, - background = customBackground, - onBackground = customForeground, + // Surfaces + val surfacePanel: Color, + val surfaceElevated: Color, - surface = customCard, - onSurface = customCardForeground, - surfaceVariant = customSecondary, - onSurfaceVariant = customMutedForeground, - surfaceTint = customPrimary, + // Utility + val borderDefault: Color, + val textMuted: Color, - inverseSurface = customForeground, - inverseOnSurface = customBackground, - inversePrimary = customPrimary, + // Compatibility + val compatibilityGood: Color, + val compatibilityGoodBackground: Color, + val compatibilityPartial: Color, + val compatibilityPartialBackground: Color, + val compatibilityUnknown: Color, + val compatibilityUnknownBackground: Color, + val compatibilityBad: Color, + val compatibilityBadBackground: Color, +) + +/** + * Dark theme color palette. + */ +private val DarkPluviaColors = PluviaColors( + statusInstalled = StatusInstalled, + statusDownloading = StatusDownloading, + statusAvailable = StatusAvailable, + statusAway = StatusAway, + statusOffline = StatusOffline, + + friendOnline = FriendOnline, + friendOffline = FriendOffline, + friendInGame = FriendInGame, + friendAwayOrSnooze = FriendAwayOrSnooze, + friendInGameAwayOrSnooze = FriendInGameAwayOrSnooze, + friendBlocked = FriendBlocked, + + accentCyan = PluviaCyan, + accentPurple = PluviaPurple, + accentPink = PluviaPink, + accentSuccess = PluviaSuccess, + accentWarning = PluviaWarning, + accentDanger = PluviaDanger, + + surfacePanel = PluviaSurface, + surfaceElevated = PluviaSurfaceElevated, + + borderDefault = PluviaBorder, + textMuted = PluviaForegroundMuted, + + compatibilityGood = CompatibilityGood, + compatibilityGoodBackground = CompatibilityGoodBg, + compatibilityPartial = CompatibilityPartial, + compatibilityPartialBackground = CompatibilityPartialBg, + compatibilityUnknown = CompatibilityUnknown, + compatibilityUnknownBackground = CompatibilityUnknownBg, + compatibilityBad = CompatibilityBad, + compatibilityBadBackground = CompatibilityBadBg, +) + +// Light theme placeholder - customize when adding light theme support +// private val LightPluviaColors = PluviaColors(...) - error = customDestructive, - onError = customForeground, - errorContainer = customDestructive.copy(alpha = 0.2f), - onErrorContainer = customForeground, +private val LocalPluviaColors = staticCompositionLocalOf { DarkPluviaColors } - outline = customMutedForeground, - outlineVariant = customMuted, +/** + * Material3 dark color scheme using Pluvia colors. + */ +private val DarkColorScheme = darkColorScheme( + primary = PluviaPrimary, + onPrimary = PluviaForeground, + primaryContainer = PluviaPrimary.copy(alpha = 0.2f), + onPrimaryContainer = PluviaForeground, + + secondary = PluviaSecondary, + onSecondary = PluviaForeground, + secondaryContainer = PluviaSecondary.copy(alpha = 0.8f), + onSecondaryContainer = PluviaForeground, + + tertiary = PluviaCyan, + onTertiary = PluviaForeground, + tertiaryContainer = PluviaCyan.copy(alpha = 0.2f), + onTertiaryContainer = PluviaForeground, + + background = PluviaBackground, + onBackground = PluviaForeground, + + surface = PluviaCard, + onSurface = PluviaForeground, + surfaceVariant = PluviaSecondary, + onSurfaceVariant = PluviaForegroundMuted, + surfaceTint = PluviaPrimary, + + inverseSurface = PluviaForeground, + inverseOnSurface = PluviaBackground, + inversePrimary = PluviaPrimary, + + error = PluviaDestructive, + onError = PluviaForeground, + errorContainer = PluviaDestructive.copy(alpha = 0.2f), + onErrorContainer = PluviaForeground, + + outline = PluviaForegroundMuted, + outlineVariant = PluviaSecondary, scrim = Color.Black.copy(alpha = 0.5f), - surfaceBright = customSecondary, - surfaceDim = customBackground, - surfaceContainer = customCard, - surfaceContainerHigh = customSecondary, - surfaceContainerHighest = customSecondary.copy(alpha = 0.9f), - surfaceContainerLow = customBackground, - surfaceContainerLowest = customBackground, + surfaceBright = PluviaSecondary, + surfaceDim = PluviaBackground, + surfaceContainer = PluviaCard, + surfaceContainerHigh = PluviaSecondary, + surfaceContainerHighest = PluviaSecondary.copy(alpha = 0.9f), + surfaceContainerLow = PluviaBackground, + surfaceContainerLowest = PluviaBackground, ) @Composable fun PluviaTheme( - seedColor: Color = pluviaSeedColor, - isDark: Boolean = true, // Force dark theme since your colors are dark + seedColor: Color = PluviaSeed, + isDark: Boolean = true, // for now, always force dark theme isAmoled: Boolean = false, style: PaletteStyle = PaletteStyle.TonalSpot, content: @Composable () -> Unit, ) { - // Use your custom color scheme instead of dynamic colors for consistency - val colorScheme = CustomDarkColorScheme + val colorScheme = DarkColorScheme + val pluviaColors = if (isDark) DarkPluviaColors else DarkPluviaColors // We can use LightPluviaColors when ready - // Override the system bars color theme. val view = LocalView.current if (!view.isInEditMode) { val window = (view.context as Activity).window val insetsController = WindowCompat.getInsetsController(window, view) - - // Always use dark system bars since we're using a dark theme insetsController.isAppearanceLightStatusBars = false insetsController.isAppearanceLightNavigationBars = false } - MaterialTheme( - colorScheme = colorScheme, - typography = PluviaTypography, - content = content, - ) + CompositionLocalProvider(LocalPluviaColors provides pluviaColors) { + MaterialTheme( + colorScheme = colorScheme, + typography = PluviaTypography, + content = content, + ) + } +} + +/** + * Accessor for Pluvia custom colors. + * Usage: PluviaTheme.colors.accentCyan + */ +object PluviaTheme { + val colors: PluviaColors + @Composable + @ReadOnlyComposable + get() = LocalPluviaColors.current +} + +/** + * Direct access to dark colors for non-Composable contexts. + * Prefer PluviaTheme.colors when inside a Composable. + */ +object DarkColors { + val statusInstalled = StatusInstalled + val statusDownloading = StatusDownloading + val statusAvailable = StatusAvailable + val statusAway = StatusAway + val statusOffline = StatusOffline + + val friendOnline = FriendOnline + val friendOffline = FriendOffline + val friendInGame = FriendInGame + val friendAwayOrSnooze = FriendAwayOrSnooze + val friendInGameAwayOrSnooze = FriendInGameAwayOrSnooze + val friendBlocked = FriendBlocked + + val accentCyan = PluviaCyan + val accentPurple = PluviaPurple + val accentPink = PluviaPink + val accentSuccess = PluviaSuccess + val accentWarning = PluviaWarning + val accentDanger = PluviaDanger + + val surfacePanel = PluviaSurface + val surfaceElevated = PluviaSurfaceElevated + + val borderDefault = PluviaBorder + val textMuted = PluviaForegroundMuted + + val compatibilityGood = CompatibilityGood + val compatibilityGoodBackground = CompatibilityGoodBg + val compatibilityPartial = CompatibilityPartial + val compatibilityPartialBackground = CompatibilityPartialBg + val compatibilityUnknown = CompatibilityUnknown + val compatibilityUnknownBackground = CompatibilityUnknownBg + val compatibilityBad = CompatibilityBad + val compatibilityBadBackground = CompatibilityBadBg } + +// Settings tile color helpers +@Composable +fun settingsTileColors(): SettingsTileColors = SettingsTileDefaults.colors( + titleColor = PluviaForeground, + subtitleColor = PluviaForegroundMuted, + actionColor = PluviaCyan, +) + +@Composable +fun settingsTileColorsAlt(): SettingsTileColors = SettingsTileDefaults.colors( + titleColor = PluviaForeground, + subtitleColor = PluviaForegroundMuted, +) + +@Composable +fun settingsTileColorsDebug(): SettingsTileColors = SettingsTileDefaults.colors( + titleColor = PluviaDestructive, + subtitleColor = PluviaForegroundMuted, + actionColor = PluviaCyan, +) diff --git a/app/src/main/java/app/gamenative/ui/util/WindowSize.kt b/app/src/main/java/app/gamenative/ui/util/WindowSize.kt new file mode 100644 index 000000000..0d1f7c43a --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/util/WindowSize.kt @@ -0,0 +1,73 @@ +package app.gamenative.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Window width size classes based on Material Design 3 guidelines. + * https://m3.material.io/foundations/layout/applying-layout/window-size-classes + */ +enum class WindowWidthClass { + COMPACT, // < 600dp + MEDIUM, // 600-840dp + EXPANDED, // > 840dp +} + +@Composable +fun rememberWindowWidthClass(): WindowWidthClass { + val configuration = LocalConfiguration.current + return remember(configuration.screenWidthDp) { + when { + configuration.screenWidthDp < 600 -> WindowWidthClass.COMPACT + configuration.screenWidthDp < 840 -> WindowWidthClass.MEDIUM + else -> WindowWidthClass.EXPANDED + } + } +} + +@Composable +fun rememberScreenWidthDp(): Int { + val configuration = LocalConfiguration.current + return configuration.screenWidthDp +} + +// TODO: Also consider if a gamepad is actually connected +@Composable +fun shouldShowGamepadUI(): Boolean { + return rememberWindowWidthClass() == WindowWidthClass.EXPANDED +} + +@Composable +fun adaptivePanelWidth(preferredWidth: Dp): Dp { + val screenWidthDp = rememberScreenWidthDp() + val maxWidth = (screenWidthDp * 0.85f).dp + return minOf(preferredWidth, maxWidth) +} + +object AdaptivePadding { + @Composable + fun horizontal(): Dp = when (rememberWindowWidthClass()) { + WindowWidthClass.COMPACT -> 12.dp + WindowWidthClass.MEDIUM -> 16.dp + WindowWidthClass.EXPANDED -> 20.dp + } + + @Composable + fun gridSpacing(): Dp = when (rememberWindowWidthClass()) { + WindowWidthClass.COMPACT -> 8.dp + WindowWidthClass.MEDIUM -> 10.dp + WindowWidthClass.EXPANDED -> 12.dp + } +} + +object AdaptiveHeroHeight { + @Composable + fun get(): Dp = when (rememberWindowWidthClass()) { + WindowWidthClass.COMPACT -> 200.dp + WindowWidthClass.MEDIUM -> 300.dp + WindowWidthClass.EXPANDED -> 420.dp + } +} diff --git a/app/src/main/res/drawable/ic_input_kbd_0.xml b/app/src/main/res/drawable/ic_input_kbd_0.xml new file mode 100644 index 000000000..d48a81a6d --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_0.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_1.xml b/app/src/main/res/drawable/ic_input_kbd_1.xml new file mode 100644 index 000000000..320005674 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_1.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_2.xml b/app/src/main/res/drawable/ic_input_kbd_2.xml new file mode 100644 index 000000000..47beae3dd --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_2.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_3.xml b/app/src/main/res/drawable/ic_input_kbd_3.xml new file mode 100644 index 000000000..f1dd658f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_3.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_4.xml b/app/src/main/res/drawable/ic_input_kbd_4.xml new file mode 100644 index 000000000..24cc0c8d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_4.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_5.xml b/app/src/main/res/drawable/ic_input_kbd_5.xml new file mode 100644 index 000000000..bd8c30668 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_5.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_6.xml b/app/src/main/res/drawable/ic_input_kbd_6.xml new file mode 100644 index 000000000..887a5f759 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_6.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_7.xml b/app/src/main/res/drawable/ic_input_kbd_7.xml new file mode 100644 index 000000000..33163e100 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_7.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_8.xml b/app/src/main/res/drawable/ic_input_kbd_8.xml new file mode 100644 index 000000000..54988a13a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_8.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_9.xml b/app/src/main/res/drawable/ic_input_kbd_9.xml new file mode 100644 index 000000000..58f728b6f --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_9.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_a.xml b/app/src/main/res/drawable/ic_input_kbd_a.xml new file mode 100644 index 000000000..9ac1773e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_a.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_alt.xml b/app/src/main/res/drawable/ic_input_kbd_alt.xml new file mode 100644 index 000000000..ecdeea64e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_alt.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_any.xml b/app/src/main/res/drawable/ic_input_kbd_any.xml new file mode 100644 index 000000000..c7d3b1cb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_any.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_apostrophe.xml b/app/src/main/res/drawable/ic_input_kbd_apostrophe.xml new file mode 100644 index 000000000..eca6fde9a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_apostrophe.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrow_down.xml b/app/src/main/res/drawable/ic_input_kbd_arrow_down.xml new file mode 100644 index 000000000..4d85c75f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrow_down.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrow_left.xml b/app/src/main/res/drawable/ic_input_kbd_arrow_left.xml new file mode 100644 index 000000000..1c7ba12b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrow_left.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrow_right.xml b/app/src/main/res/drawable/ic_input_kbd_arrow_right.xml new file mode 100644 index 000000000..555c74d5c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrow_right.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrow_up.xml b/app/src/main/res/drawable/ic_input_kbd_arrow_up.xml new file mode 100644 index 000000000..04aaa62fd --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrow_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows.xml b/app/src/main/res/drawable/ic_input_kbd_arrows.xml new file mode 100644 index 000000000..b6063975e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_all.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_all.xml new file mode 100644 index 000000000..32a70c3ec --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_all.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_down.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_down.xml new file mode 100644 index 000000000..43466c5a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_down.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_horizontal.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_horizontal.xml new file mode 100644 index 000000000..228459936 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_horizontal.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_left.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_left.xml new file mode 100644 index 000000000..5eb7bd703 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_left.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_none.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_none.xml new file mode 100644 index 000000000..aafb94c63 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_none.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_right.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_right.xml new file mode 100644 index 000000000..a77887ec9 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_right.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_up.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_up.xml new file mode 100644 index 000000000..0af19c90e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_up.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_arrows_vertical.xml b/app/src/main/res/drawable/ic_input_kbd_arrows_vertical.xml new file mode 100644 index 000000000..323310123 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_arrows_vertical.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_asterisk.xml b/app/src/main/res/drawable/ic_input_kbd_asterisk.xml new file mode 100644 index 000000000..b50efd5d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_asterisk.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_b.xml b/app/src/main/res/drawable/ic_input_kbd_b.xml new file mode 100644 index 000000000..71029e514 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_b.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_backspace.xml b/app/src/main/res/drawable/ic_input_kbd_backspace.xml new file mode 100644 index 000000000..1835c0319 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_backspace.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_backspace_icon.xml b/app/src/main/res/drawable/ic_input_kbd_backspace_icon.xml new file mode 100644 index 000000000..590481f77 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_backspace_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_backspace_icon_alternative.xml b/app/src/main/res/drawable/ic_input_kbd_backspace_icon_alternative.xml new file mode 100644 index 000000000..0b98430cd --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_backspace_icon_alternative.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_bracket_close.xml b/app/src/main/res/drawable/ic_input_kbd_bracket_close.xml new file mode 100644 index 000000000..c79c22641 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_bracket_close.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_bracket_greater.xml b/app/src/main/res/drawable/ic_input_kbd_bracket_greater.xml new file mode 100644 index 000000000..73d4f48d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_bracket_greater.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_bracket_less.xml b/app/src/main/res/drawable/ic_input_kbd_bracket_less.xml new file mode 100644 index 000000000..d2dcc6dee --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_bracket_less.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_bracket_open.xml b/app/src/main/res/drawable/ic_input_kbd_bracket_open.xml new file mode 100644 index 000000000..a7941b0f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_bracket_open.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_c.xml b/app/src/main/res/drawable/ic_input_kbd_c.xml new file mode 100644 index 000000000..520898357 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_c.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_capslock.xml b/app/src/main/res/drawable/ic_input_kbd_capslock.xml new file mode 100644 index 000000000..a28b9b52a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_capslock.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_capslock_icon.xml b/app/src/main/res/drawable/ic_input_kbd_capslock_icon.xml new file mode 100644 index 000000000..30ebf0342 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_capslock_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_caret.xml b/app/src/main/res/drawable/ic_input_kbd_caret.xml new file mode 100644 index 000000000..61555b9a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_caret.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_colon.xml b/app/src/main/res/drawable/ic_input_kbd_colon.xml new file mode 100644 index 000000000..4f6fb9d5a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_colon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_comma.xml b/app/src/main/res/drawable/ic_input_kbd_comma.xml new file mode 100644 index 000000000..90a75a0f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_comma.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_command.xml b/app/src/main/res/drawable/ic_input_kbd_command.xml new file mode 100644 index 000000000..77e8567d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_command.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_ctrl.xml b/app/src/main/res/drawable/ic_input_kbd_ctrl.xml new file mode 100644 index 000000000..a4d147948 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_ctrl.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_d.xml b/app/src/main/res/drawable/ic_input_kbd_d.xml new file mode 100644 index 000000000..3b580cc31 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_d.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_delete.xml b/app/src/main/res/drawable/ic_input_kbd_delete.xml new file mode 100644 index 000000000..718dffb22 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_delete.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_e.xml b/app/src/main/res/drawable/ic_input_kbd_e.xml new file mode 100644 index 000000000..ec4df4a8f --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_e.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_end.xml b/app/src/main/res/drawable/ic_input_kbd_end.xml new file mode 100644 index 000000000..fd0833570 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_end.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_enter.xml b/app/src/main/res/drawable/ic_input_kbd_enter.xml new file mode 100644 index 000000000..947b0657b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_enter.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_equals.xml b/app/src/main/res/drawable/ic_input_kbd_equals.xml new file mode 100644 index 000000000..d9339edc5 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_equals.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_escape.xml b/app/src/main/res/drawable/ic_input_kbd_escape.xml new file mode 100644 index 000000000..545787b3a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_escape.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_exclamation.xml b/app/src/main/res/drawable/ic_input_kbd_exclamation.xml new file mode 100644 index 000000000..d61dfe2ea --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_exclamation.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f.xml b/app/src/main/res/drawable/ic_input_kbd_f.xml new file mode 100644 index 000000000..baa886e86 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f1.xml b/app/src/main/res/drawable/ic_input_kbd_f1.xml new file mode 100644 index 000000000..f42bce3ab --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f1.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f10.xml b/app/src/main/res/drawable/ic_input_kbd_f10.xml new file mode 100644 index 000000000..c41338d97 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f10.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f11.xml b/app/src/main/res/drawable/ic_input_kbd_f11.xml new file mode 100644 index 000000000..dfe1932d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f11.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f12.xml b/app/src/main/res/drawable/ic_input_kbd_f12.xml new file mode 100644 index 000000000..10f6a4929 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f12.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f2.xml b/app/src/main/res/drawable/ic_input_kbd_f2.xml new file mode 100644 index 000000000..5bb12bc39 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f2.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f3.xml b/app/src/main/res/drawable/ic_input_kbd_f3.xml new file mode 100644 index 000000000..5826b3fea --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f3.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f4.xml b/app/src/main/res/drawable/ic_input_kbd_f4.xml new file mode 100644 index 000000000..2b5e1aaff --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f4.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f5.xml b/app/src/main/res/drawable/ic_input_kbd_f5.xml new file mode 100644 index 000000000..f8e959e88 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f5.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f6.xml b/app/src/main/res/drawable/ic_input_kbd_f6.xml new file mode 100644 index 000000000..f333a75d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f6.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f7.xml b/app/src/main/res/drawable/ic_input_kbd_f7.xml new file mode 100644 index 000000000..4ae202386 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f7.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f8.xml b/app/src/main/res/drawable/ic_input_kbd_f8.xml new file mode 100644 index 000000000..652edaca7 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f8.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_f9.xml b/app/src/main/res/drawable/ic_input_kbd_f9.xml new file mode 100644 index 000000000..e4874271c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_f9.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_function.xml b/app/src/main/res/drawable/ic_input_kbd_function.xml new file mode 100644 index 000000000..aefb8c606 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_function.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_g.xml b/app/src/main/res/drawable/ic_input_kbd_g.xml new file mode 100644 index 000000000..e3413b191 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_g.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_h.xml b/app/src/main/res/drawable/ic_input_kbd_h.xml new file mode 100644 index 000000000..48877368c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_h.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_home.xml b/app/src/main/res/drawable/ic_input_kbd_home.xml new file mode 100644 index 000000000..14b68b43a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_home.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_i.xml b/app/src/main/res/drawable/ic_input_kbd_i.xml new file mode 100644 index 000000000..209121080 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_i.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_insert.xml b/app/src/main/res/drawable/ic_input_kbd_insert.xml new file mode 100644 index 000000000..e3def1ee0 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_insert.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_j.xml b/app/src/main/res/drawable/ic_input_kbd_j.xml new file mode 100644 index 000000000..7951cdc51 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_j.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_k.xml b/app/src/main/res/drawable/ic_input_kbd_k.xml new file mode 100644 index 000000000..b70833138 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_k.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_keyboard.xml b/app/src/main/res/drawable/ic_input_kbd_keyboard.xml new file mode 100644 index 000000000..d8d5dd826 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_keyboard.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_l.xml b/app/src/main/res/drawable/ic_input_kbd_l.xml new file mode 100644 index 000000000..08ca870c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_l.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_m.xml b/app/src/main/res/drawable/ic_input_kbd_m.xml new file mode 100644 index 000000000..2997cdf49 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_m.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_minus.xml b/app/src/main/res/drawable/ic_input_kbd_minus.xml new file mode 100644 index 000000000..a6c579d98 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_minus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse.xml b/app/src/main/res/drawable/ic_input_kbd_mouse.xml new file mode 100644 index 000000000..36615161c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_horizontal.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_horizontal.xml new file mode 100644 index 000000000..1606d7066 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_horizontal.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_left.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_left.xml new file mode 100644 index 000000000..7ea332a96 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_left.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_move.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_move.xml new file mode 100644 index 000000000..2fb35c632 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_move.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_right.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_right.xml new file mode 100644 index 000000000..c1b93d99f --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_right.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_scroll.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll.xml new file mode 100644 index 000000000..fac8d688e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_down.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_down.xml new file mode 100644 index 000000000..4aa7ef134 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_down.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_up.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_up.xml new file mode 100644 index 000000000..bf948566a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_up.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_vertical.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_vertical.xml new file mode 100644 index 000000000..f42fb11ae --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_scroll_vertical.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_small.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_small.xml new file mode 100644 index 000000000..4daabd131 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_small.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_mouse_vertical.xml b/app/src/main/res/drawable/ic_input_kbd_mouse_vertical.xml new file mode 100644 index 000000000..9ca2593b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_mouse_vertical.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_n.xml b/app/src/main/res/drawable/ic_input_kbd_n.xml new file mode 100644 index 000000000..a79a18fc5 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_n.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_numlock.xml b/app/src/main/res/drawable/ic_input_kbd_numlock.xml new file mode 100644 index 000000000..822292f3b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_numlock.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_numpad_enter.xml b/app/src/main/res/drawable/ic_input_kbd_numpad_enter.xml new file mode 100644 index 000000000..70db5f9b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_numpad_enter.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_numpad_plus.xml b/app/src/main/res/drawable/ic_input_kbd_numpad_plus.xml new file mode 100644 index 000000000..08386d8d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_numpad_plus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_o.xml b/app/src/main/res/drawable/ic_input_kbd_o.xml new file mode 100644 index 000000000..049ceca4c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_o.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_option.xml b/app/src/main/res/drawable/ic_input_kbd_option.xml new file mode 100644 index 000000000..7fee0c846 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_option.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_outline.xml b/app/src/main/res/drawable/ic_input_kbd_outline.xml new file mode 100644 index 000000000..7a193e6e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_outline.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_p.xml b/app/src/main/res/drawable/ic_input_kbd_p.xml new file mode 100644 index 000000000..b758d244e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_p.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_page_down.xml b/app/src/main/res/drawable/ic_input_kbd_page_down.xml new file mode 100644 index 000000000..5d72b0ba6 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_page_down.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_page_up.xml b/app/src/main/res/drawable/ic_input_kbd_page_up.xml new file mode 100644 index 000000000..dabc80c15 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_page_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_period.xml b/app/src/main/res/drawable/ic_input_kbd_period.xml new file mode 100644 index 000000000..9aa937d3b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_period.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_plus.xml b/app/src/main/res/drawable/ic_input_kbd_plus.xml new file mode 100644 index 000000000..e401e800b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_plus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_printscreen.xml b/app/src/main/res/drawable/ic_input_kbd_printscreen.xml new file mode 100644 index 000000000..5e116ad9b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_printscreen.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_q.xml b/app/src/main/res/drawable/ic_input_kbd_q.xml new file mode 100644 index 000000000..b9d09ea7e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_q.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_question.xml b/app/src/main/res/drawable/ic_input_kbd_question.xml new file mode 100644 index 000000000..002aab706 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_question.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_quote.xml b/app/src/main/res/drawable/ic_input_kbd_quote.xml new file mode 100644 index 000000000..8c48ede69 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_quote.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_r.xml b/app/src/main/res/drawable/ic_input_kbd_r.xml new file mode 100644 index 000000000..e133330d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_r.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_return.xml b/app/src/main/res/drawable/ic_input_kbd_return.xml new file mode 100644 index 000000000..53fdd171b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_return.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_s.xml b/app/src/main/res/drawable/ic_input_kbd_s.xml new file mode 100644 index 000000000..09fa9b2c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_s.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_semicolon.xml b/app/src/main/res/drawable/ic_input_kbd_semicolon.xml new file mode 100644 index 000000000..b3329c4fb --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_semicolon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_shift.xml b/app/src/main/res/drawable/ic_input_kbd_shift.xml new file mode 100644 index 000000000..ac3421408 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_shift.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_shift_icon.xml b/app/src/main/res/drawable/ic_input_kbd_shift_icon.xml new file mode 100644 index 000000000..719dc635e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_shift_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_slash_back.xml b/app/src/main/res/drawable/ic_input_kbd_slash_back.xml new file mode 100644 index 000000000..20ea7e2b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_slash_back.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_slash_forward.xml b/app/src/main/res/drawable/ic_input_kbd_slash_forward.xml new file mode 100644 index 000000000..a126dbab7 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_slash_forward.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_space.xml b/app/src/main/res/drawable/ic_input_kbd_space.xml new file mode 100644 index 000000000..d8b9bebc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_space.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_space_icon.xml b/app/src/main/res/drawable/ic_input_kbd_space_icon.xml new file mode 100644 index 000000000..807406996 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_space_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_t.xml b/app/src/main/res/drawable/ic_input_kbd_t.xml new file mode 100644 index 000000000..6408c5723 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_t.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_tab.xml b/app/src/main/res/drawable/ic_input_kbd_tab.xml new file mode 100644 index 000000000..ace7ff702 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_tab.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_tab_icon.xml b/app/src/main/res/drawable/ic_input_kbd_tab_icon.xml new file mode 100644 index 000000000..fa0de14ac --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_tab_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_tab_icon_alternative.xml b/app/src/main/res/drawable/ic_input_kbd_tab_icon_alternative.xml new file mode 100644 index 000000000..9d9208842 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_tab_icon_alternative.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_tilde.xml b/app/src/main/res/drawable/ic_input_kbd_tilde.xml new file mode 100644 index 000000000..25693e795 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_tilde.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_u.xml b/app/src/main/res/drawable/ic_input_kbd_u.xml new file mode 100644 index 000000000..5e3a7e516 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_u.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_v.xml b/app/src/main/res/drawable/ic_input_kbd_v.xml new file mode 100644 index 000000000..1587c96ec --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_v.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_w.xml b/app/src/main/res/drawable/ic_input_kbd_w.xml new file mode 100644 index 000000000..44f126ec0 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_w.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_win.xml b/app/src/main/res/drawable/ic_input_kbd_win.xml new file mode 100644 index 000000000..3643152d9 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_win.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_x.xml b/app/src/main/res/drawable/ic_input_kbd_x.xml new file mode 100644 index 000000000..5eefe8e53 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_x.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_y.xml b/app/src/main/res/drawable/ic_input_kbd_y.xml new file mode 100644 index 000000000..1a6440455 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_y.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_kbd_z.xml b/app/src/main/res/drawable/ic_input_kbd_z.xml new file mode 100644 index 000000000..15103667c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_kbd_z.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_finger_one.xml b/app/src/main/res/drawable/ic_input_touch_finger_one.xml new file mode 100644 index 000000000..426b21606 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_finger_one.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_finger_two.xml b/app/src/main/res/drawable/ic_input_touch_finger_two.xml new file mode 100644 index 000000000..01952988b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_finger_two.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_hand_closed.xml b/app/src/main/res/drawable/ic_input_touch_hand_closed.xml new file mode 100644 index 000000000..6fb222e2a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_hand_closed.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_hand_open.xml b/app/src/main/res/drawable/ic_input_touch_hand_open.xml new file mode 100644 index 000000000..e4d3d662a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_hand_open.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_rotate_left.xml b/app/src/main/res/drawable/ic_input_touch_rotate_left.xml new file mode 100644 index 000000000..ad8d43d36 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_rotate_left.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_rotate_right.xml b/app/src/main/res/drawable/ic_input_touch_rotate_right.xml new file mode 100644 index 000000000..4b7d0cba7 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_rotate_right.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_down.xml b/app/src/main/res/drawable/ic_input_touch_swipe_down.xml new file mode 100644 index 000000000..e1c2575ef --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_down.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_horizontal.xml b/app/src/main/res/drawable/ic_input_touch_swipe_horizontal.xml new file mode 100644 index 000000000..b89649454 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_horizontal.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_left.xml b/app/src/main/res/drawable/ic_input_touch_swipe_left.xml new file mode 100644 index 000000000..7a7b1bc8f --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_left.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_move.xml b/app/src/main/res/drawable/ic_input_touch_swipe_move.xml new file mode 100644 index 000000000..7d51acb0a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_move.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_right.xml b/app/src/main/res/drawable/ic_input_touch_swipe_right.xml new file mode 100644 index 000000000..1cda1a8b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_right.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_two_down.xml b/app/src/main/res/drawable/ic_input_touch_swipe_two_down.xml new file mode 100644 index 000000000..087f855ae --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_two_down.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_two_horizontal.xml b/app/src/main/res/drawable/ic_input_touch_swipe_two_horizontal.xml new file mode 100644 index 000000000..97a3092fd --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_two_horizontal.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_two_left.xml b/app/src/main/res/drawable/ic_input_touch_swipe_two_left.xml new file mode 100644 index 000000000..12bc3e3e5 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_two_left.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_two_move.xml b/app/src/main/res/drawable/ic_input_touch_swipe_two_move.xml new file mode 100644 index 000000000..4d4589c35 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_two_move.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_two_right.xml b/app/src/main/res/drawable/ic_input_touch_swipe_two_right.xml new file mode 100644 index 000000000..b40c09294 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_two_right.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_two_up.xml b/app/src/main/res/drawable/ic_input_touch_swipe_two_up.xml new file mode 100644 index 000000000..1984295ae --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_two_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_two_vertical.xml b/app/src/main/res/drawable/ic_input_touch_swipe_two_vertical.xml new file mode 100644 index 000000000..862b236e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_two_vertical.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_up.xml b/app/src/main/res/drawable/ic_input_touch_swipe_up.xml new file mode 100644 index 000000000..9dd755670 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_swipe_vertical.xml b/app/src/main/res/drawable/ic_input_touch_swipe_vertical.xml new file mode 100644 index 000000000..55acb5991 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_swipe_vertical.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_tap.xml b/app/src/main/res/drawable/ic_input_touch_tap.xml new file mode 100644 index 000000000..da0435f6d --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_tap.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_tap_double.xml b/app/src/main/res/drawable/ic_input_touch_tap_double.xml new file mode 100644 index 000000000..d454fda8b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_tap_double.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_tap_hold.xml b/app/src/main/res/drawable/ic_input_touch_tap_hold.xml new file mode 100644 index 000000000..f5ef6d66c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_tap_hold.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_two.xml b/app/src/main/res/drawable/ic_input_touch_two.xml new file mode 100644 index 000000000..e7312f6ae --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_two.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_two_double.xml b/app/src/main/res/drawable/ic_input_touch_two_double.xml new file mode 100644 index 000000000..8f50c35ad --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_two_double.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_two_hold.xml b/app/src/main/res/drawable/ic_input_touch_two_hold.xml new file mode 100644 index 000000000..bc2a38004 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_two_hold.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_zoom_in.xml b/app/src/main/res/drawable/ic_input_touch_zoom_in.xml new file mode 100644 index 000000000..2cb211e2d --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_zoom_in.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_touch_zoom_out.xml b/app/src/main/res/drawable/ic_input_touch_zoom_out.xml new file mode 100644 index 000000000..b59120606 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_touch_zoom_out.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_a.xml b/app/src/main/res/drawable/ic_input_xbox_button_a.xml new file mode 100644 index 000000000..f45fac37a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_a.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_b.xml b/app/src/main/res/drawable/ic_input_xbox_button_b.xml new file mode 100644 index 000000000..f4ea819c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_b.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_back.xml b/app/src/main/res/drawable/ic_input_xbox_button_back.xml new file mode 100644 index 000000000..88eb63c4c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_back.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_back_icon.xml b/app/src/main/res/drawable/ic_input_xbox_button_back_icon.xml new file mode 100644 index 000000000..3f9b7b97a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_back_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_color_a.xml b/app/src/main/res/drawable/ic_input_xbox_button_color_a.xml new file mode 100644 index 000000000..71e1683b5 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_color_a.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_color_b.xml b/app/src/main/res/drawable/ic_input_xbox_button_color_b.xml new file mode 100644 index 000000000..d2c452914 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_color_b.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_color_x.xml b/app/src/main/res/drawable/ic_input_xbox_button_color_x.xml new file mode 100644 index 000000000..91e6eca01 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_color_x.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_color_y.xml b/app/src/main/res/drawable/ic_input_xbox_button_color_y.xml new file mode 100644 index 000000000..84bab174a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_color_y.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_menu.xml b/app/src/main/res/drawable/ic_input_xbox_button_menu.xml new file mode 100644 index 000000000..9439c8f7b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_menu.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_share.xml b/app/src/main/res/drawable/ic_input_xbox_button_share.xml new file mode 100644 index 000000000..217b1bc33 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_share.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_start.xml b/app/src/main/res/drawable/ic_input_xbox_button_start.xml new file mode 100644 index 000000000..d4e9a5505 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_start.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_start_icon.xml b/app/src/main/res/drawable/ic_input_xbox_button_start_icon.xml new file mode 100644 index 000000000..01a0e3f42 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_start_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_view.xml b/app/src/main/res/drawable/ic_input_xbox_button_view.xml new file mode 100644 index 000000000..d8d2bfb66 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_view.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_x.xml b/app/src/main/res/drawable/ic_input_xbox_button_x.xml new file mode 100644 index 000000000..ebf49b47e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_x.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_button_y.xml b/app/src/main/res/drawable/ic_input_xbox_button_y.xml new file mode 100644 index 000000000..4a89f27af --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_button_y.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_controller_xbox360.xml b/app/src/main/res/drawable/ic_input_xbox_controller_xbox360.xml new file mode 100644 index 000000000..dd269b15a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_controller_xbox360.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_controller_xbox_adaptive.xml b/app/src/main/res/drawable/ic_input_xbox_controller_xbox_adaptive.xml new file mode 100644 index 000000000..a74cb20f2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_controller_xbox_adaptive.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_controller_xboxone.xml b/app/src/main/res/drawable/ic_input_xbox_controller_xboxone.xml new file mode 100644 index 000000000..13db769c1 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_controller_xboxone.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_controller_xboxseries.xml b/app/src/main/res/drawable/ic_input_xbox_controller_xboxseries.xml new file mode 100644 index 000000000..f13da42d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_controller_xboxseries.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad.xml b/app/src/main/res/drawable/ic_input_xbox_dpad.xml new file mode 100644 index 000000000..546e8aec0 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_all.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_all.xml new file mode 100644 index 000000000..30e847b35 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_all.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_down.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_down.xml new file mode 100644 index 000000000..4de249306 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_down.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_horizontal.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_horizontal.xml new file mode 100644 index 000000000..e90c41a1d --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_horizontal.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_left.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_left.xml new file mode 100644 index 000000000..f41472747 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_left.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_none.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_none.xml new file mode 100644 index 000000000..285d27a8b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_none.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_right.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_right.xml new file mode 100644 index 000000000..ea4c5c15f --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_right.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round.xml new file mode 100644 index 000000000..4520f92d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round_all.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round_all.xml new file mode 100644 index 000000000..e3efc7964 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round_all.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round_down.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round_down.xml new file mode 100644 index 000000000..b3875f2ed --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round_down.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round_horizontal.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round_horizontal.xml new file mode 100644 index 000000000..9dcf4f1fe --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round_horizontal.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round_left.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round_left.xml new file mode 100644 index 000000000..0d774a4be --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round_left.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round_right.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round_right.xml new file mode 100644 index 000000000..7693e0602 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round_right.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round_up.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round_up.xml new file mode 100644 index 000000000..d167c1929 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round_up.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_round_vertical.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_round_vertical.xml new file mode 100644 index 000000000..94e4844c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_round_vertical.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_up.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_up.xml new file mode 100644 index 000000000..6f4d7cd7c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_up.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_dpad_vertical.xml b/app/src/main/res/drawable/ic_input_xbox_dpad_vertical.xml new file mode 100644 index 000000000..24d03822c --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_dpad_vertical.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_elite_paddle_bottom_left.xml b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_bottom_left.xml new file mode 100644 index 000000000..0b2ea789f --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_bottom_left.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_elite_paddle_bottom_right.xml b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_bottom_right.xml new file mode 100644 index 000000000..4216857d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_bottom_right.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_elite_paddle_top_left.xml b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_top_left.xml new file mode 100644 index 000000000..9d70d69ff --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_top_left.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_elite_paddle_top_right.xml b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_top_right.xml new file mode 100644 index 000000000..3d8de9494 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_elite_paddle_top_right.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_guide.xml b/app/src/main/res/drawable/ic_input_xbox_guide.xml new file mode 100644 index 000000000..0775579d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_guide.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_lb.xml b/app/src/main/res/drawable/ic_input_xbox_lb.xml new file mode 100644 index 000000000..d8f5f9a43 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_lb.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_ls.xml b/app/src/main/res/drawable/ic_input_xbox_ls.xml new file mode 100644 index 000000000..a269d0451 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_ls.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_lt.xml b/app/src/main/res/drawable/ic_input_xbox_lt.xml new file mode 100644 index 000000000..610d0b158 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_lt.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_rb.xml b/app/src/main/res/drawable/ic_input_xbox_rb.xml new file mode 100644 index 000000000..e0f27a009 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_rb.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_rs.xml b/app/src/main/res/drawable/ic_input_xbox_rs.xml new file mode 100644 index 000000000..fab8879a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_rs.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_rt.xml b/app/src/main/res/drawable/ic_input_xbox_rt.xml new file mode 100644 index 000000000..07b496bb5 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_rt.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l.xml new file mode 100644 index 000000000..14795b979 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l_down.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l_down.xml new file mode 100644 index 000000000..04a36578b --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l_down.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l_horizontal.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l_horizontal.xml new file mode 100644 index 000000000..d27fc8398 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l_horizontal.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l_left.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l_left.xml new file mode 100644 index 000000000..329a9c434 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l_left.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l_press.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l_press.xml new file mode 100644 index 000000000..b2a3db962 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l_press.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l_right.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l_right.xml new file mode 100644 index 000000000..b856941e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l_right.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l_up.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l_up.xml new file mode 100644 index 000000000..2a81d4ab3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l_up.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_l_vertical.xml b/app/src/main/res/drawable/ic_input_xbox_stick_l_vertical.xml new file mode 100644 index 000000000..e77d4cf01 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_l_vertical.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r.xml new file mode 100644 index 000000000..b20371a4f --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r_down.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r_down.xml new file mode 100644 index 000000000..2d29868f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r_down.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r_horizontal.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r_horizontal.xml new file mode 100644 index 000000000..d1e69c13a --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r_horizontal.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r_left.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r_left.xml new file mode 100644 index 000000000..6376de8f8 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r_left.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r_press.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r_press.xml new file mode 100644 index 000000000..f4809b20e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r_press.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r_right.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r_right.xml new file mode 100644 index 000000000..020351b72 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r_right.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r_up.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r_up.xml new file mode 100644 index 000000000..b67d078a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r_up.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_r_vertical.xml b/app/src/main/res/drawable/ic_input_xbox_stick_r_vertical.xml new file mode 100644 index 000000000..152cbbd9e --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_r_vertical.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_side_l.xml b/app/src/main/res/drawable/ic_input_xbox_stick_side_l.xml new file mode 100644 index 000000000..9d4a108bd --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_side_l.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_side_r.xml b/app/src/main/res/drawable/ic_input_xbox_stick_side_r.xml new file mode 100644 index 000000000..21332e886 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_side_r.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_top_l.xml b/app/src/main/res/drawable/ic_input_xbox_stick_top_l.xml new file mode 100644 index 000000000..0b9d78ff1 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_top_l.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_stick_top_r.xml b/app/src/main/res/drawable/ic_input_xbox_stick_top_r.xml new file mode 100644 index 000000000..944310ce5 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_stick_top_r.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_input_xbox_xboxseries.xml b/app/src/main/res/drawable/ic_input_xbox_xboxseries.xml new file mode 100644 index 000000000..f13da42d3 --- /dev/null +++ b/app/src/main/res/drawable/ic_input_xbox_xboxseries.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 800f74a37..cdd445dbb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,14 @@ Downloads Friends + + All + Steam + GoG + Epic + Installed + Local + Custom Games No paths added @@ -172,6 +180,8 @@ Disconnected Reset On-Screen Controls Open Navigation Menu + Quick Menu + Back Touchpad Help Hide on-screen controls with controller Automatically hide on-screen controls when a physical controller is connected @@ -387,6 +397,10 @@ Update Available Game Information Status + Online + Away + Invisible + System Size Location Developer @@ -422,9 +436,18 @@ Family + Connecting to Steam… No connection to Steam Retry Steam Connection Continue Offline + Reconnecting to Steam… + Disconnected from Steam + Disconnected from Steam servers + Logging in… + Login failed + Session ended + Offline mode + Retry @@ -593,7 +616,12 @@ Enable Box86/64 Logs Write Box86 & Box64 debug output to file View latest crash + Shows the most recent crash log + No recent crash logs found View game debug log + Shows the latest Wine/Box64 debug log + No Wine debug logs found + No channels selected Clear Preferences [Closes App] Logs out the client and wipes local preference data. Clear Local Database @@ -638,8 +666,14 @@ Privacy Policy Welcome Back Sign in to access your Steam library + Credentials + QR Code Username + Enter your Steam username Password + Enter your password + Show password + Hide password Remember session Sign In QR Code Failed @@ -703,6 +737,7 @@ Support open-source PC gaming on Android by sharing the app with your friends or becoming a member on Ko-fi. Join on Ko-fi Share + Check out GameNative - play your PC Steam games on Android, with full support for cloud saves!\nhttps://gamenative.app\nJoin the community: https://discord.gg/2hKv4VfZfE Did the game work? Join the Discord to get support to fix your game or improve performance. Open Discord @@ -765,6 +800,7 @@ App Icon: Hachi Alternate App Icon: rhapsody_mdr Loading supporters… + Unable to load supporters Members Supporters No supporters yet. @@ -784,8 +820,10 @@ Installing Not installed Family Shared + Shared Compatible - Compatibility Unknown + GPU Compatible + Unknown Not Compatible @@ -813,7 +851,48 @@ %1$d games • %2$d installed Steam Custom Games + Source Layout + Options + Back + Cloud + Options + Play Time + Last Played + + + Options + Close options + Sort By + Installed First + Name (A-Z) + Name (Z-A) + Recently Played + Size (Smallest) + Size (Largest) + + + Options + Quick Actions + Game Management + Container + Cloud Saves + Help & Info + + + Press B to close + Menu + + + Select + Refresh + Details + Add Game + System + + + %d result + %d results Login @@ -862,6 +941,7 @@ Settings + Customize your experience Content installed successfully