use viewmodels and parse tsv files and display them

This commit is contained in:
2023-07-11 18:35:01 +03:00
parent 6cf6cd6f08
commit f4d9b90937
23 changed files with 1155 additions and 644 deletions
+5 -3
View File
@@ -61,7 +61,7 @@ android {
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.4.4" kotlinCompilerExtensionVersion = "1.4.8"
} }
buildToolsVersion = "33.0.1" buildToolsVersion = "33.0.1"
kotlinOptions { kotlinOptions {
@@ -76,10 +76,12 @@ repositories {
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
implementation("androidx.activity:activity-compose:1.7.2") implementation("androidx.activity:activity-compose:1.7.2")
implementation(platform("androidx.compose:compose-bom:2022.10.00")) implementation(platform("androidx.compose:compose-bom:2022.10.00"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.core:core-ktx:1.10.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2022.10.00")) androidTestImplementation(platform("androidx.compose:compose-bom:2022.10.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
// Jetpack Compose // Jetpack Compose
@@ -88,7 +90,7 @@ dependencies {
androidTestImplementation(composeBom) androidTestImplementation(composeBom)
// Material 3 // Material 3
implementation("androidx.compose.material3:material3:1.1.0") implementation("androidx.compose.material3:material3:1.1.1")
implementation("androidx.compose.material:material-icons-extended:1.4.3") implementation("androidx.compose.material:material-icons-extended:1.4.3")
// Android Studio Preview support // Android Studio Preview support
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
@@ -100,7 +102,7 @@ dependencies {
// Compose extras library for controlling system ui and etc. // Compose extras library for controlling system ui and etc.
implementation("com.google.accompanist:accompanist-systemuicontroller:0.31.2-alpha") implementation("com.google.accompanist:accompanist-systemuicontroller:0.31.2-alpha")
implementation("com.github.doyaaaaaken:kotlin-csv-jvm:1.9.1") implementation("com.github.IllusionMan1212:kotlin-csv:cedb7c5ab2")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+1
View File
@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".NPSApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
@@ -0,0 +1,25 @@
package com.illusionware.npsbrowser
import android.app.Application
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
private const val PREFERENCES_NAME = "preferences"
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = PREFERENCES_NAME
)
class NPSApp: Application() {
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository
override fun onCreate() {
super.onCreate()
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
onboardingPreferencesRepository = OnboardingPreferencesRepository(dataStore)
}
}
@@ -1,246 +0,0 @@
package com.illusionware.npsbrowser
import android.content.Context
import android.net.Uri
import android.os.Environment
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
enum class ConsoleType {
PSVITA,
PS3,
PSP,
PSX,
PSM,
}
enum class DataType {
GAMES,
DLC,
THEMES,
}
enum class Layout {
LIST,
GRID,
}
class Preferences(private val context: Context) {
companion object {
private val Context.dataStore by preferencesDataStore("settings")
private val APP_THEME = intPreferencesKey("app_theme")
private val DATA_LAYOUT = intPreferencesKey("layout")
private val SEEN_ONBOARDING = booleanPreferencesKey("seen_onboarding")
private val PSV_GAMES = stringPreferencesKey("psv_games")
private val PS3_GAMES = stringPreferencesKey("ps3_games")
private val PSP_GAMES = stringPreferencesKey("psp_games")
private val PSX_GAMES = stringPreferencesKey("psx_games")
private val PSM_GAMES = stringPreferencesKey("psm_games")
private val PSV_DLC = stringPreferencesKey("psv_dlc")
private val PS3_DLC = stringPreferencesKey("ps3_dlc")
private val PSP_DLC = stringPreferencesKey("psp_dlc")
private val PSV_THEMES = stringPreferencesKey("psv_themes")
private val HMAC_KEY = stringPreferencesKey("hmac_key")
private val DOWNLOAD_DIR = stringPreferencesKey("download_dir")
private val UNPACK_DIR = stringPreferencesKey("unpack_dir")
private val UNPACK_IN_DOWNLOAD = booleanPreferencesKey("unpack_in_download")
private val DELETE_AFTER_UNPACK = booleanPreferencesKey("delete_after_unpack")
private val PKG2ZIP_PARAMS = stringPreferencesKey("pkg2zip_params")
private val PKG2ZIP_AUTO_DECRYPT = booleanPreferencesKey("pkg2zip_auto_decrypt")
}
val theme = context.dataStore.data.map { prefs ->
prefs[APP_THEME] ?: 2
}
val layout = context.dataStore.data.map{ prefs ->
prefs[DATA_LAYOUT] ?: Layout.LIST.ordinal
}
val seenOnboarding = context.dataStore.data.map{ prefs ->
prefs[SEEN_ONBOARDING] ?: false
}
val psvGames = context.dataStore.data.map{ prefs ->
prefs[PSV_GAMES] ?: ""
}
val ps3Games = context.dataStore.data.map{ prefs ->
prefs[PS3_GAMES] ?: ""
}
val pspGames = context.dataStore.data.map{ prefs ->
prefs[PSP_GAMES] ?: ""
}
val psxGames = context.dataStore.data.map{ prefs ->
prefs[PSX_GAMES] ?: ""
}
val psmGames = context.dataStore.data.map{ prefs ->
prefs[PSM_GAMES] ?: ""
}
val psvDLC = context.dataStore.data.map{ prefs ->
prefs[PSV_DLC] ?: ""
}
val ps3DLC = context.dataStore.data.map{ prefs ->
prefs[PS3_DLC] ?: ""
}
val pspDLC = context.dataStore.data.map{ prefs ->
prefs[PSP_DLC] ?: ""
}
val psvThemes = context.dataStore.data.map{ prefs ->
prefs[PSV_THEMES] ?: ""
}
val hmacKey = context.dataStore.data.map{ prefs ->
prefs[HMAC_KEY] ?: ""
}
val downloadDir = context.dataStore.data.map{ prefs ->
prefs[DOWNLOAD_DIR] ?: Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.path ?: ""
}
val unpackDir = context.dataStore.data.map{ prefs ->
prefs[UNPACK_DIR] ?: Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.path ?: ""
}
val unpackInDownload = context.dataStore.data.map{ prefs ->
prefs[UNPACK_IN_DOWNLOAD] ?: false
}
val deleteAfterUnpack = context.dataStore.data.map{ prefs ->
prefs[DELETE_AFTER_UNPACK] ?: false
}
val pkg2zipParams = context.dataStore.data.map{ prefs ->
prefs[PKG2ZIP_PARAMS] ?: "-x {pkgFile} \"{zRifKey}\""
}
val pkg2zipAutoDecrypt = context.dataStore.data.map{ prefs ->
prefs[PKG2ZIP_AUTO_DECRYPT] ?: true
}
suspend fun changeTheme(theme: Int) {
context.dataStore.edit { prefs ->
prefs[APP_THEME] = theme
}
}
suspend fun changeLayout(layout: Int) {
context.dataStore.edit { prefs ->
prefs[DATA_LAYOUT] = layout
}
}
suspend fun finishOnboarding() {
context.dataStore.edit { prefs ->
prefs[SEEN_ONBOARDING] = true
}
}
suspend fun setTSVFile(console: ConsoleType, contentType: DataType, file: Uri) {
context.dataStore.edit { prefs ->
when (console) {
ConsoleType.PSVITA -> {
when (contentType) {
DataType.GAMES -> prefs[PSV_GAMES] = file.toString()
DataType.DLC -> prefs[PSV_DLC] = file.toString()
DataType.THEMES -> prefs[PSV_THEMES] = file.toString()
}
}
ConsoleType.PS3 -> {
when (contentType) {
DataType.GAMES -> prefs[PS3_GAMES] = file.toString()
DataType.DLC -> prefs[PS3_DLC] = file.toString()
else -> {}
}
}
ConsoleType.PSP -> {
when (contentType) {
DataType.GAMES -> prefs[PSP_GAMES] = file.toString()
DataType.DLC -> prefs[PSP_DLC] = file.toString()
else -> {}
}
}
ConsoleType.PSX -> {
when (contentType) {
DataType.GAMES -> prefs[PSX_GAMES] = file.toString()
else -> {}
}
}
ConsoleType.PSM -> {
when (contentType) {
DataType.GAMES -> prefs[PSM_GAMES] = file.toString()
else -> {}
}
}
}
}
}
suspend fun getAvailableTsvs(): List<Pair<ConsoleType, DataType>> {
val list = mutableListOf<Pair<ConsoleType, DataType>>()
if (psvGames.first().isNotEmpty()) list.add(Pair(ConsoleType.PSVITA, DataType.GAMES))
if (psvDLC.first().isNotEmpty()) list.add(Pair(ConsoleType.PSVITA, DataType.DLC))
if (psvThemes.first().isNotEmpty()) list.add(Pair(ConsoleType.PSVITA, DataType.THEMES))
if (ps3Games.first().isNotEmpty()) list.add(Pair(ConsoleType.PS3, DataType.GAMES))
if (ps3DLC.first().isNotEmpty()) list.add(Pair(ConsoleType.PS3, DataType.DLC))
if (pspGames.first().isNotEmpty()) list.add(Pair(ConsoleType.PSP, DataType.GAMES))
if (pspDLC.first().isNotEmpty()) list.add(Pair(ConsoleType.PSP, DataType.DLC))
if (psxGames.first().isNotEmpty()) list.add(Pair(ConsoleType.PSX, DataType.GAMES))
if (psmGames.first().isNotEmpty()) list.add(Pair(ConsoleType.PSM, DataType.GAMES))
return list
}
suspend fun setHMACKey(key: String) {
context.dataStore.edit { prefs ->
prefs[HMAC_KEY] = key
}
}
suspend fun setDownloadDir(dir: String) {
context.dataStore.edit { prefs ->
prefs[DOWNLOAD_DIR] = dir
}
}
suspend fun setUnpackDir(dir: String) {
context.dataStore.edit { prefs ->
prefs[UNPACK_DIR] = dir
}
}
suspend fun setUnpackInDownload(unpackInDownload: Boolean) {
context.dataStore.edit { prefs ->
prefs[UNPACK_IN_DOWNLOAD] = unpackInDownload
}
}
suspend fun setDeleteAfterUnpack(deleteAfterUnpack: Boolean) {
context.dataStore.edit { prefs ->
prefs[DELETE_AFTER_UNPACK] = deleteAfterUnpack
}
}
suspend fun setPkg2zipParams(params: String) {
context.dataStore.edit { prefs ->
prefs[PKG2ZIP_PARAMS] = params
}
}
suspend fun setPkg2zipAutoDecrypt(autoDecrypt: Boolean) {
context.dataStore.edit { prefs ->
prefs[PKG2ZIP_AUTO_DECRYPT] = autoDecrypt
}
}
}
@@ -3,9 +3,7 @@ package com.illusionware.npsbrowser.activities
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -16,7 +14,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -25,6 +22,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
@@ -32,6 +31,8 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -47,7 +48,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -69,31 +69,34 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.illusionware.npsbrowser.ConsoleType
import com.illusionware.npsbrowser.Layout
import com.illusionware.npsbrowser.Preferences
import com.illusionware.npsbrowser.R import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItem
import com.illusionware.npsbrowser.ui.components.*
import com.illusionware.npsbrowser.ui.pages.OnBoarding
import com.illusionware.npsbrowser.ui.pages.SettingsScreen
import com.illusionware.npsbrowser.ui.theme.ColorAccent import com.illusionware.npsbrowser.ui.theme.ColorAccent
import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight
import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme
import com.illusionware.npsbrowser.ui.theme.Typography import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.ui.components.* import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.ui.pages.SettingsScreen import com.illusionware.npsbrowser.viewmodels.PackageListViewModel
import com.illusionware.npsbrowser.ui.pages.OnBoarding import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
val PlaceholderColor = Color(0x1F888888) val PlaceholderColor = Color(0x1F888888)
@@ -102,25 +105,24 @@ val redHatDisplayFamily = FontFamily(
) )
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
Router() val settingsViewModel: SettingsViewModel = viewModel(
} factory = SettingsViewModel.Factory
} )
} val onboardingViewModel: OnboardingViewModel = viewModel(
factory = OnboardingViewModel.Factory
)
@Composable val onboardingPrefs = onboardingViewModel.uiState.collectAsStateWithLifecycle().value
fun Router() { val appTheme = runBlocking { settingsViewModel.getAppTheme() }
val prefs = Preferences(LocalContext.current) val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
var theme by remember { runBlocking { mutableStateOf(prefs.theme.first()) } }
var seenOnboarding by remember { runBlocking { mutableStateOf(prefs.seenOnboarding.first()) } }
val navController = rememberNavController() val navController = rememberNavController()
val darkTheme = when (theme) { val darkTheme = when (appTheme) {
0 -> false 0 -> false
1 -> true 1 -> true
else -> isSystemInDarkTheme() else -> isSystemInDarkTheme()
@@ -129,51 +131,54 @@ fun Router() {
NPSBrowserTheme(darkTheme = darkTheme) { NPSBrowserTheme(darkTheme = darkTheme) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = if (seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent color = if (onboardingPrefs.seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (seenOnboarding) "Home" else "Onboarding", startDestination = if (onboardingPrefs.seenOnboarding) "Home" else "Onboarding",
) { ) {
composable(route = "Home") { composable(route = "Home") {
HomePage( HomePage(
navigateToSettings = { navController.navigate("Settings") } navigateToSettings = { navController.navigate("Settings") },
settingsPrefs = settingsPrefs,
settingsViewModel = settingsViewModel,
) )
} }
composable(route = "Settings") { composable(route = "Settings") {
SettingsScreen( SettingsScreen(
navigationGoBack = { navController.popBackStack() }, navigationGoBack = { navController.popBackStack() },
onThemeChange = { v -> theme = v },
) )
} }
composable(route = "Onboarding") { composable(route = "Onboarding") {
OnBoarding( OnBoarding(
navigateToHome = { navigateToHome = {
navController.popBackStack() navController.popBackStack()
seenOnboarding = true
navController.navigate("Home") navController.navigate("Home")
}, },
darkTheme = darkTheme darkTheme = darkTheme,
) )
} }
} }
} }
} }
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@Preview fun HomePage(
fun HomePage(navigateToSettings: () -> Unit = {}) { navigateToSettings: () -> Unit = {},
val prefs = Preferences(LocalContext.current) settingsPrefs: SettingsPreferences,
settingsViewModel: SettingsViewModel,
) {
var isSearchOpen by rememberSaveable { mutableStateOf(false)} var isSearchOpen by rememberSaveable { mutableStateOf(false)}
var searchQuery by rememberSaveable { mutableStateOf("")} var searchQuery by rememberSaveable { mutableStateOf("")}
val searchBar = remember { FocusRequester() } val searchBar = remember { FocusRequester() }
var layoutDialogOpen by remember { mutableStateOf(false) } var layoutDialogOpen by remember { mutableStateOf(false) }
val tsvs by remember { runBlocking { mutableStateOf(prefs.getAvailableTsvs()) }} val tsvs = settingsViewModel.availableTsvs()
var selectedBarItem by remember { mutableStateOf(ConsoleType.PSVITA) } var selectedBarItem by remember { mutableStateOf(ConsoleType.PSVITA) }
// val noTsvs by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -239,7 +244,7 @@ fun HomePage(navigateToSettings: () -> Unit = {}) {
tooltip = stringResource(id = R.string.layout), tooltip = stringResource(id = R.string.layout),
onClick = { layoutDialogOpen = true } onClick = { layoutDialogOpen = true }
) { ) {
Icon(imageVector = Icons.Filled.GridView, contentDescription = stringResource( Icon(imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.Filled.ViewList, contentDescription = stringResource(
id = R.string.layout id = R.string.layout
)) ))
} }
@@ -267,16 +272,16 @@ fun HomePage(navigateToSettings: () -> Unit = {}) {
if (tsvs.isEmpty()) { if (tsvs.isEmpty()) {
NoTsvs(navigateToSettings = navigateToSettings) NoTsvs(navigateToSettings = navigateToSettings)
} else { } else {
// when (selectedBarItem) { PackageList(selectedBarItem, settingsPrefs = settingsPrefs)
// ConsoleType.PSVITA -> VitaPage(searchQuery = searchQuery)
// ConsoleType.PS3 -> PS3Page(searchQuery = searchQuery)
// }
PackageList(selectedBarItem)
} }
} }
if (layoutDialogOpen) { if (layoutDialogOpen) {
LayoutDialog(onDismiss = { layoutDialogOpen = false }) LayoutDialog(
onDismiss = { layoutDialogOpen = false },
prefs = settingsPrefs,
viewModel = settingsViewModel,
)
} }
} }
} }
@@ -368,36 +373,63 @@ fun NoTsvs(padding: PaddingValues = PaddingValues(0.dp), navigateToSettings: ()
} }
@Composable @Composable
@Preview fun PackageList(
fun PackageList(type: ConsoleType = ConsoleType.PSVITA) { type: ConsoleType = ConsoleType.PSVITA,
val prefs = Preferences(LocalContext.current) viewModel: PackageListViewModel = viewModel(
val layout = prefs.layout.collectAsState(initial = Layout.LIST.ordinal).value factory = PackageListViewModel.Factory
// val thing = when (type) { ),
// ConsoleType.PSVITA -> { settingsPrefs: SettingsPreferences,
// var list = ArrayList<Package>() ) {
// list.add(Package("test")) val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
// val gs = prefs.psvGames.collectAsState(initial = emptyList<Package>())
// list.add(gs.value) val packages = when (type) {
// } ConsoleType.PSVITA -> {
// ConsoleType.PS3 -> { uiState.packages.filter { it.consoleType == ConsoleType.PSVITA }
// prefs.ps3Games.collectAsState(initial = emptyList()) }
// } ConsoleType.PS3 -> {
// ConsoleType.PSP -> { uiState.packages.filter { it.consoleType == ConsoleType.PS3 }
// prefs.pspGames.collectAsState(initial = emptyList()) }
// } ConsoleType.PSP -> {
// ConsoleType.PSX -> { uiState.packages.filter { it.consoleType == ConsoleType.PSP }
// prefs.psxGames.collectAsState(initial = emptyList()) }
// } ConsoleType.PSX -> {
// ConsoleType.PSM -> { uiState.packages.filter { it.consoleType == ConsoleType.PSX }
// prefs.psmGames.collectAsState(initial = emptyList()) }
// } ConsoleType.PSM -> {
// } uiState.packages.filter { it.consoleType == ConsoleType.PSM }
}
}
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
if (layout == Layout.LIST.ordinal) { if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
if (packages.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
"You haven't added any TSVs for this console yet",
style = Typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.outline
)
}
} else {
if (settingsPrefs.layout == ItemLayout.LIST.ordinal) {
LazyColumn { LazyColumn {
items(15) { items(packages) {
PackageListItem() PackageListItem(it)
} }
} }
} else { } else {
@@ -405,17 +437,18 @@ fun PackageList(type: ConsoleType = ConsoleType.PSVITA) {
columns = GridCells.Adaptive(140.dp), columns = GridCells.Adaptive(140.dp),
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
) { ) {
items(15) { items(packages) {
PackageGridItem() PackageGridItem(it)
}
}
} }
} }
} }
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun PackageListItem() { fun PackageListItem(item: PackageItem) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -434,7 +467,7 @@ fun PackageListItem() {
) { ) {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data("https://picsum.photos/200") .data("TODO:")
.crossfade(true) .crossfade(true)
.error(R.drawable.default_game_icon) .error(R.drawable.default_game_icon)
.build(), .build(),
@@ -451,7 +484,7 @@ fun PackageListItem() {
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Text(
text = "10 Second Ninja X", text = item.name,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
style = Typography.bodyLarge, style = Typography.bodyLarge,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -466,28 +499,36 @@ fun PackageListItem() {
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) { ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = "US") Text(text = item.region)
Text(text = "3.69") if (item.minFW.isNotEmpty()) {
Text(text = "223.8MB") Text(text = String.format("%.2f", item.minFW.toFloat()))
Tag(title = "GAME", small = true) }
if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize)
}
Tag(title = item.dataType.name, small = true)
} }
} }
} }
} }
Column { Column {
if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") {
Icon( Icon(
painter = painterResource(id = R.drawable.no_url_icon), painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL", contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A) tint = Color(0xFFCB5A5A)
) )
}
if (item.consoleType != ConsoleType.PSX && (item.zRif.isEmpty() && item.rap.isEmpty())) {
Icon( Icon(
painter = painterResource(id = R.drawable.no_key_icon), painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No Zrif Key", contentDescription = "No License Key",
tint = Color(0xFFCB5A5A) tint = Color(0xFFCB5A5A)
) )
} }
} }
} }
}
} }
@Composable @Composable
@@ -512,8 +553,7 @@ fun Tag(title: String, small: Boolean = false) {
} }
@Composable @Composable
@Preview fun PackageGridItem(item: PackageItem) {
fun PackageGridItem() {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -529,7 +569,7 @@ fun PackageGridItem() {
content = { content = {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data("https://picsum.photos/200") .data("TODO:")
.crossfade(true) .crossfade(true)
.error(R.drawable.default_game_icon) .error(R.drawable.default_game_icon)
.build(), .build(),
@@ -540,7 +580,7 @@ fun PackageGridItem() {
.fillMaxWidth(), .fillMaxWidth(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
Tag(title = "GAME") Tag(title = item.dataType.name)
} }
) { measurables, constraints -> ) { measurables, constraints ->
val imagePlaceable = measurables[0].measure(constraints) val imagePlaceable = measurables[0].measure(constraints)
@@ -555,7 +595,7 @@ fun PackageGridItem() {
} }
} }
Text( Text(
text = "10 Second Ninja X", text = item.name,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
style = Typography.bodyLarge, style = Typography.bodyLarge,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -566,32 +606,41 @@ fun PackageGridItem() {
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) { ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = "US") Text(text = item.region)
Text(text = "3.69") if (item.minFW.isNotEmpty()) {
Text(text = "223.8MB") Text(text = String.format("%.2f", item.minFW.toFloat()))
}
if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize)
}
} }
} }
Row { Row {
if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") {
Icon( Icon(
painter = painterResource(id = R.drawable.no_url_icon), painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL", contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A) tint = Color(0xFFCB5A5A)
) )
}
if (item.zRif.isEmpty() && item.rap.isEmpty()) {
Icon( Icon(
painter = painterResource(id = R.drawable.no_key_icon), painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No Zrif Key", contentDescription = "No License Key",
tint = Color(0xFFCB5A5A) tint = Color(0xFFCB5A5A)
) )
} }
} }
} }
}
} }
@Composable @Composable
fun LayoutDialog(onDismiss: () -> Unit) { fun LayoutDialog(
val prefs = Preferences(LocalContext.current) onDismiss: () -> Unit,
val layout = prefs.layout.collectAsState(initial = Layout.LIST.ordinal).value prefs: SettingsPreferences,
viewModel: SettingsViewModel
) {
NPSAlertDialog( NPSAlertDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
title = stringResource(id = R.string.layout), title = stringResource(id = R.string.layout),
@@ -604,13 +653,13 @@ fun LayoutDialog(onDismiss: () -> Unit) {
Column { Column {
NPSRadioButton( NPSRadioButton(
text = "List", text = "List",
selected = layout == Layout.LIST.ordinal, selected = prefs.layout == ItemLayout.LIST.ordinal,
onClick = { CoroutineScope(Dispatchers.IO).launch { prefs.changeLayout(Layout.LIST.ordinal) } }, onClick = { viewModel.setLayout(ItemLayout.LIST) },
) )
NPSRadioButton( NPSRadioButton(
text = "Grid", text = "Grid",
selected = layout == Layout.GRID.ordinal, selected = prefs.layout == ItemLayout.GRID.ordinal,
onClick = { CoroutineScope(Dispatchers.IO).launch { prefs.changeLayout(Layout.GRID.ordinal) } }, onClick = { viewModel.setLayout(ItemLayout.GRID) },
) )
} }
} }
@@ -0,0 +1,36 @@
package com.illusionware.npsbrowser.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
data class OnboardingPreferences(
val seenOnboarding: Boolean,
)
class OnboardingPreferencesRepository(
private val dataStore: DataStore<Preferences>,
) {
companion object {
private val SEEN_ONBOARDING = booleanPreferencesKey("seen_onboarding")
}
fun getPreferences(): Flow<OnboardingPreferences> =
runBlocking { dataStore.data.map { mapOnboardingSettings(it) } }
suspend fun setSeenOnboarding(seen: Boolean) {
dataStore.edit { prefs ->
prefs[SEEN_ONBOARDING] = seen
}
}
private fun mapOnboardingSettings(prefs: Preferences): OnboardingPreferences {
return OnboardingPreferences(
prefs[SEEN_ONBOARDING] ?: false,
)
}
}
@@ -0,0 +1,219 @@
package com.illusionware.npsbrowser.data
import android.net.Uri
import android.os.Environment
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
val defaultDownloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + "/NPSBrowser"
enum class ItemLayout {
LIST,
GRID,
}
enum class Theme {
LIGHT,
DARK,
SYSTEM,
}
data class SettingsPreferences(
val appTheme: Int,
val layout: Int,
val psvGames: String,
val ps3Games: String,
val pspGames: String,
val psxGames: String,
val psmGames: String,
val psvDlc: String,
val ps3Dlc: String,
val pspDlc: String,
val psvThemes: String,
val hmacKey: String,
val downloadDir: String,
val unpackDir: String,
val unpackInDownload: Boolean,
val deleteAfterUnpack: Boolean,
val pkg2zipParams: String,
val pkg2zipAutoDecrypt: Boolean,
)
class SettingsPreferencesRepository(
private val dataStore: DataStore<Preferences>
) {
companion object {
private val APP_THEME = intPreferencesKey("app_theme")
private val LAYOUT = intPreferencesKey("layout")
private val PSV_GAMES = stringPreferencesKey("psv_games")
private val PS3_GAMES = stringPreferencesKey("ps3_games")
private val PSP_GAMES = stringPreferencesKey("psp_games")
private val PSX_GAMES = stringPreferencesKey("psx_games")
private val PSM_GAMES = stringPreferencesKey("psm_games")
private val PSV_DLC = stringPreferencesKey("psv_dlc")
private val PS3_DLC = stringPreferencesKey("ps3_dlc")
private val PSP_DLC = stringPreferencesKey("psp_dlc")
private val PSV_THEMES = stringPreferencesKey("psv_themes")
private val HMAC_KEY = stringPreferencesKey("hmac_key")
private val DOWNLOAD_DIR = stringPreferencesKey("download_dir")
private val UNPACK_DIR = stringPreferencesKey("unpack_dir")
private val UNPACK_IN_DOWNLOAD = booleanPreferencesKey("unpack_in_download")
private val DELETE_AFTER_UNPACK = booleanPreferencesKey("delete_after_unpack")
private val PKG2ZIP_PARAMS = stringPreferencesKey("pkg2zip_params")
private val PKG2ZIP_AUTO_DECRYPT = booleanPreferencesKey("pkg2zip_auto_decrypt")
}
private val tsvFiles = MutableSharedFlow<Triple<String, ConsoleType, PackageItemType>>()
fun getPreferences(): Flow<SettingsPreferences> =
dataStore.data.map { mapSettingsPreferences(it) }
suspend fun availableTsvs(): MutableList<Triple<String, ConsoleType, PackageItemType>> {
val list = mutableListOf<Triple<String, ConsoleType, PackageItemType>>()
return dataStore.data.map {
if (it[PSV_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSV_GAMES] ?: "", ConsoleType.PSVITA, PackageItemType.GAME))
if (it[PSV_DLC]?.isNotEmpty() == true) list.add(Triple(it[PSV_DLC] ?: "", ConsoleType.PSVITA, PackageItemType.DLC))
if (it[PSV_THEMES]?.isNotEmpty() == true) list.add(Triple(it[PSV_THEMES] ?: "", ConsoleType.PSVITA, PackageItemType.THEME))
if (it[PS3_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PS3_GAMES] ?: "", ConsoleType.PS3, PackageItemType.GAME))
if (it[PS3_DLC]?.isNotEmpty() == true) list.add(Triple(it[PS3_DLC] ?: "", ConsoleType.PS3, PackageItemType.DLC))
if (it[PSP_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSP_GAMES] ?: "", ConsoleType.PSP, PackageItemType.GAME))
if (it[PSP_DLC]?.isNotEmpty() == true) list.add(Triple(it[PSP_DLC] ?: "", ConsoleType.PSP, PackageItemType.DLC))
if (it[PSX_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSX_GAMES] ?: "", ConsoleType.PSX, PackageItemType.GAME))
if (it[PSM_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSM_GAMES] ?: "", ConsoleType.PSM, PackageItemType.GAME))
list
}.first()
}
suspend fun setTheme(theme: Theme) {
dataStore.edit { prefs ->
prefs[APP_THEME] = theme.ordinal
}
}
suspend fun setLayout(layout: ItemLayout) {
dataStore.edit { prefs ->
prefs[LAYOUT] = layout.ordinal
}
}
suspend fun setHMACKey(key: String) {
dataStore.edit { prefs ->
prefs[HMAC_KEY] = key
}
}
suspend fun setDownloadDir(dir: String) {
dataStore.edit { prefs ->
prefs[DOWNLOAD_DIR] = dir
}
}
suspend fun setUnpackDir(dir: String) {
dataStore.edit { prefs ->
prefs[UNPACK_DIR] = dir
}
}
suspend fun setUnpackInDownload(unpackInDownload: Boolean) {
dataStore.edit { prefs ->
prefs[UNPACK_IN_DOWNLOAD] = unpackInDownload
}
}
suspend fun setDeleteAfterUnpack(deleteAfterUnpack: Boolean) {
dataStore.edit { prefs ->
prefs[DELETE_AFTER_UNPACK] = deleteAfterUnpack
}
}
suspend fun setPkg2zipParams(params: String) {
dataStore.edit { prefs ->
prefs[PKG2ZIP_PARAMS] = params
}
}
suspend fun setPkg2zipAutoDecrypt(autoDecrypt: Boolean) {
dataStore.edit { prefs ->
prefs[PKG2ZIP_AUTO_DECRYPT] = autoDecrypt
}
}
fun tsvFiles() = tsvFiles
suspend fun setTSVFile(console: ConsoleType, contentType: PackageItemType, file: Uri) {
tsvFiles.emit(Triple(file.toString(), console, contentType))
dataStore.edit { prefs ->
when (console) {
ConsoleType.PSVITA -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSV_GAMES] = file.toString()
PackageItemType.DLC -> prefs[PSV_DLC] = file.toString()
PackageItemType.THEME -> prefs[PSV_THEMES] = file.toString()
}
}
ConsoleType.PS3 -> {
when (contentType) {
PackageItemType.GAME -> prefs[PS3_GAMES] = file.toString()
PackageItemType.DLC -> prefs[PS3_DLC] = file.toString()
else -> {}
}
}
ConsoleType.PSP -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSP_GAMES] = file.toString()
PackageItemType.DLC -> prefs[PSP_DLC] = file.toString()
else -> {}
}
}
ConsoleType.PSX -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSX_GAMES] = file.toString()
else -> {}
}
}
ConsoleType.PSM -> {
when (contentType) {
PackageItemType.GAME -> prefs[PSM_GAMES] = file.toString()
else -> {}
}
}
}
}
}
private fun mapSettingsPreferences(prefs: Preferences): SettingsPreferences {
return SettingsPreferences(
prefs[APP_THEME] ?: Theme.SYSTEM.ordinal,
prefs[LAYOUT] ?: ItemLayout.LIST.ordinal,
prefs[PSV_GAMES] ?: "",
prefs[PS3_GAMES] ?: "",
prefs[PSP_GAMES] ?: "",
prefs[PSX_GAMES] ?: "",
prefs[PSM_GAMES] ?: "",
prefs[PSV_DLC] ?: "",
prefs[PS3_DLC] ?: "",
prefs[PSP_DLC] ?: "",
prefs[PSV_THEMES] ?: "",
prefs[HMAC_KEY] ?: "",
prefs[DOWNLOAD_DIR] ?: defaultDownloadDir,
prefs[UNPACK_DIR] ?: defaultDownloadDir,
prefs[UNPACK_IN_DOWNLOAD] ?: false,
prefs[DELETE_AFTER_UNPACK] ?: false,
prefs[PKG2ZIP_PARAMS] ?: "-x {pkgFile} \"{zRifKey}\"",
prefs[PKG2ZIP_AUTO_DECRYPT] ?: true,
)
}
}
@@ -0,0 +1,9 @@
package com.illusionware.npsbrowser.model
enum class ConsoleType {
PSVITA,
PS3,
PSP,
PSX,
PSM,
}
@@ -0,0 +1,18 @@
package com.illusionware.npsbrowser.model
data class PackageItem(
val titleId: String,
val region: String,
val name: String,
val pkgUrl: String,
val contentId: String,
val modificationDate: String,
val pkgSize: String,
val sha256: String,
val zRif: String,
val rap: String,
val minFW: String,
val consoleType: ConsoleType,
val dataType: PackageItemType,
)
@@ -0,0 +1,7 @@
package com.illusionware.npsbrowser.model
enum class PackageItemType {
GAME,
DLC,
THEME,
}
@@ -2,6 +2,7 @@ package com.illusionware.npsbrowser.ui.pages
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -68,7 +69,6 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -77,21 +77,45 @@ import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.illusionware.npsbrowser.ConsoleType import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.illusionware.npsbrowser.DataType import androidx.lifecycle.viewmodel.compose.viewModel
import com.illusionware.npsbrowser.Preferences import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
import com.illusionware.npsbrowser.ui.components.NPSIconButton import com.illusionware.npsbrowser.ui.components.NPSIconButton
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
const val PAGE_COUNT = 3 const val PAGE_COUNT = 3
val readexProFamily = FontFamily(
Font(R.font.readex_pro_extra_light, FontWeight.ExtraLight),
Font(R.font.readex_pro_light, FontWeight.Light),
Font(R.font.readex_pro_regular, FontWeight.Normal),
Font(R.font.readex_pro_medium, FontWeight.Medium),
Font(R.font.readex_pro_semi_bold, FontWeight.SemiBold),
Font(R.font.readex_pro_bold, FontWeight.Bold),
)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint("SourceLockedOrientationActivity")
@Composable @Composable
fun OnBoarding(navigateToHome: () -> Unit = {}, darkTheme: Boolean = false) { fun OnBoarding(
navigateToHome: () -> Unit = {},
darkTheme: Boolean = false,
onboardingViewModel: OnboardingViewModel = viewModel(
factory = OnboardingViewModel.Factory
),
settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
),
) {
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = 0, initialPage = 0,
@@ -99,13 +123,14 @@ fun OnBoarding(navigateToHome: () -> Unit = {}, darkTheme: Boolean = false) {
) )
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val darkIcons = when (pagerState.currentPage) { val darkIcons = when (pagerState.currentPage) {
0 -> false 0 -> false
else -> true else -> true
} }
DisposableEffect(systemUiController, darkIcons) { DisposableEffect(systemUiController) {
systemUiController.setStatusBarColor( systemUiController.setStatusBarColor(
color = Color.Transparent, color = Color.Transparent,
darkIcons = darkIcons && !darkTheme darkIcons = darkIcons && !darkTheme
@@ -123,18 +148,48 @@ fun OnBoarding(navigateToHome: () -> Unit = {}, darkTheme: Boolean = false) {
HorizontalPager(pageCount = PAGE_COUNT, state = pagerState) { page -> HorizontalPager(pageCount = PAGE_COUNT, state = pagerState) { page ->
when (page) { when (page) {
0 -> OnBoardingInitial(incrementPage = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }) 0 -> OnBoardingInitial(
incrementPage = {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
},
)
1 -> OnBoardingSetupTSVs( 1 -> OnBoardingSetupTSVs(
page = page, page = page,
incrementPage = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, incrementPage = {
skipToEnd = { scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) } } scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
},
skipToEnd = {
scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) }
},
settingsViewModel,
) )
// 2 -> OnBoardingStoragePermission(
// page = page,
// incrementPage = {
// scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
// },
// decrementPage = {
// scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
// },
// skipToEnd = {
// scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) }
// },
// )
2 -> OnBoardingSetupSettings( 2 -> OnBoardingSetupSettings(
page = page, page = page,
incrementPage = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, incrementPage = {
decrementPage = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } }, scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
skipToEnd = { scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) } }, },
navigateToHome = navigateToHome decrementPage = {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) }
},
skipToEnd = {
scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) }
},
navigateToHome = navigateToHome,
settingsViewModel,
onboardingViewModel,
settingsPrefs,
) )
} }
} }
@@ -232,15 +287,12 @@ fun OnBoardingInitial(incrementPage: () -> Unit = {}) {
} }
@Composable @Composable
@Preview
fun OnBoardingSetupTSVs( fun OnBoardingSetupTSVs(
page: Int = 1, page: Int = 1,
incrementPage: () -> Unit = {}, incrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {} skipToEnd: () -> Unit = {},
settingsViewModel: SettingsViewModel,
) { ) {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
Scaffold { padding -> Scaffold { padding ->
Column( Column(
Modifier Modifier
@@ -291,49 +343,49 @@ fun OnBoardingSetupTSVs(
TSVGroup(title = "PS Vita") { TSVGroup(title = "PS Vita") {
TSVButton( TSVButton(
"Games", "Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSVITA, DataType.GAMES, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it) }
) )
TSVButton( TSVButton(
"Themes", "Themes",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSVITA, DataType.THEMES, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it) }
) )
TSVButton( TSVButton(
"DLC", "DLC",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSVITA, DataType.DLC, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it) }
) )
} }
TSVGroup(title = "PSP") { TSVGroup(title = "PSP") {
TSVButton( TSVButton(
"Games", "Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSP, DataType.GAMES, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it) }
) )
TSVButton( TSVButton(
"DLC", "DLC",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSP, DataType.DLC, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSP, PackageItemType.DLC, it) }
) )
Box(modifier = Modifier.width(110.dp)) Box(modifier = Modifier.width(110.dp))
} }
TSVGroup(title = "PS3") { TSVGroup(title = "PS3") {
TSVButton( TSVButton(
"Games", "Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PS3, DataType.GAMES, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it) }
) )
TSVButton( TSVButton(
"DLC", "DLC",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PS3, DataType.DLC, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PS3, PackageItemType.DLC, it) }
) )
Box(modifier = Modifier.width(110.dp)) Box(modifier = Modifier.width(110.dp))
} }
TSVGroup(title = "PSX") { TSVGroup(title = "PSX") {
TSVButton( TSVButton(
"Games", "Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSX, DataType.GAMES, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it) }
) )
} }
TSVGroup(title = "PSM") { TSVGroup(title = "PSM") {
TSVButton( TSVButton(
"Games", "Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSM, DataType.GAMES, it) }} onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSM, PackageItemType.GAME, it) }
) )
} }
} }
@@ -400,19 +452,24 @@ fun TSVGroup(title: String, content: @Composable RowScope.() -> Unit) {
@Composable @Composable
fun TSVButton(title: String, onFinish: (Uri) -> Unit) { fun TSVButton(title: String, onFinish: (Uri) -> Unit) {
val context = LocalContext.current
var hasSelected by rememberSaveable { mutableStateOf(false) } var hasSelected by rememberSaveable { mutableStateOf(false) }
val filePicker = rememberLauncherForActivityResult( val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.OpenDocument(),
) { fileUri -> ) { fileUri ->
if (fileUri != null) { if (fileUri != null) {
context.contentResolver.takePersistableUriPermission(
fileUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
onFinish(fileUri) onFinish(fileUri)
hasSelected = true hasSelected = true
} }
} }
FilledTonalButton( FilledTonalButton(
onClick = { onClick = {
filePicker.launch("text/tab-separated-values") filePicker.launch(arrayOf("text/tab-separated-values"))
}, },
modifier = Modifier modifier = Modifier
.width(110.dp) .width(110.dp)
@@ -470,34 +527,114 @@ fun TSVButton(title: String, onFinish: (Uri) -> Unit) {
} }
} }
//@Composable
//fun OnBoardingStoragePermission(
// page: Int = 2,
// incrementPage: () -> Unit = {},
// decrementPage: () -> Unit = {},
// skipToEnd: () -> Unit = {},
//) {
// val hasPermission = when (PackageManager.PERMISSION_GRANTED) {
// ContextCompat.checkSelfPermission(
// LocalContext.current,
// Manifest.permission.WRITE_EXTERNAL_STORAGE
// ) -> true
// else -> false
// }
//
// val permissionLegacy = rememberLauncherForActivityResult(
// contract = ActivityResultContracts.RequestPermission(),
// ) {}
//
// Scaffold { padding ->
// Column(
// Modifier
// .padding(padding)
// .padding(16.dp)
// .fillMaxSize(),
// verticalArrangement = Arrangement.SpaceBetween
// ) {
// Column(
// Modifier
// .padding(vertical = 32.dp)
// .weight(1.0f)
// .fillMaxHeight(),
// horizontalAlignment = Alignment.CenterHorizontally,
// ) {
// Image(
// painter = painterResource(id = R.drawable.folder_bubbles),
// contentDescription = "Folder Image",
// contentScale = ContentScale.Crop,
// modifier = Modifier.fillMaxWidth()
// )
// Text(
// text = "Storage".uppercase(),
// style = TextStyle.Default.copy(
// fontWeight = FontWeight.Bold,
// fontSize = 28.sp,
// fontFamily = readexProFamily
// ),
// )
// Spacer(modifier = Modifier.height(24.dp))
// Text(
// "Granting storage permission allows us to bypass newer Android limitations and choose any directory as your download directory",
// style = TextStyle.Default.copy(
// fontSize = 20.sp,
// fontFamily = readexProFamily
// ),
// textAlign = TextAlign.Center,
// )
// Spacer(modifier = Modifier
// .fillMaxHeight()
// .weight(1.0f))
// Button(
// onClick = {
// permissionLegacy.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
// },
// modifier = Modifier.fillMaxWidth(),
// enabled = !hasPermission,
// ) {
// Text(
// text = if (hasPermission) "Granted" else "Grant",
// style = TextStyle.Default.copy(
// fontSize = 18.sp,
// fontFamily = readexProFamily
// ),
// )
// }
// }
// Column(Modifier.fillMaxWidth()) {
// OnBoardingBottomButtons(
// page = page,
// incrementPage = incrementPage,
// decrementPage = decrementPage,
// skipToEnd = skipToEnd
// )
// }
// }
// }
//}
@Composable @Composable
@Preview
fun OnBoardingSetupSettings( fun OnBoardingSetupSettings(
page: Int = 2, page: Int = 2,
incrementPage: () -> Unit = {}, incrementPage: () -> Unit = {},
decrementPage: () -> Unit = {}, decrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {}, skipToEnd: () -> Unit = {},
navigateToHome: () -> Unit = {}, navigateToHome: () -> Unit = {},
settingsViewModel: SettingsViewModel,
onboardingViewModel: OnboardingViewModel,
settingsPrefs: SettingsPreferences,
) { ) {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
var hmacKey by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } var hmacKey by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
var pkg2zipArgs = prefs.pkg2zipParams.collectAsState(initial = "").value
val downloadDir = prefs.downloadDir.collectAsState(initial = "").value
val unpackDir = prefs.unpackDir.collectAsState(initial = "").value
val unpackInDownload = prefs.unpackInDownload.collectAsState(initial = false).value
val deleteAfterUnpack = prefs.deleteAfterUnpack.collectAsState(initial = false).value
val autoDecrypt = prefs.pkg2zipAutoDecrypt.collectAsState(initial = true).value
val downloadDirPicker = rememberLauncherForActivityResult( val downloadDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(), contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri -> ) { dirUri ->
if (dirUri != null) { if (dirUri != null) {
scope.launch { settingsViewModel.setDownloadDir(dirUri.toString())
prefs.setDownloadDir(dirUri.toString())
}
} }
} }
@@ -505,17 +642,13 @@ fun OnBoardingSetupSettings(
contract = ActivityResultContracts.OpenDocumentTree(), contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri -> ) { dirUri ->
if (dirUri != null) { if (dirUri != null) {
scope.launch { settingsViewModel.setUnpackDir(dirUri.toString())
prefs.setUnpackDir(dirUri.toString())
}
} }
} }
fun setHmacKey(key: String) { fun setHmacKey(key: String) {
if (key.isNotEmpty()) { if (key.isNotEmpty()) {
scope.launch { settingsViewModel.setHMACKey(key)
prefs.setHMACKey(key)
}
} }
} }
@@ -592,20 +725,16 @@ fun OnBoardingSetupSettings(
Column { Column {
Toggleable( Toggleable(
title = "Unpack in download directory", title = "Unpack in download directory",
checked = unpackInDownload, checked = settingsPrefs.unpackInDownload,
onCheckedChange = { onCheckedChange = {
scope.launch { settingsViewModel.setUnpackInDownload(it)
prefs.setUnpackInDownload(it)
}
}, },
) )
Toggleable( Toggleable(
title = "Delete package after unpacking", title = "Delete package after unpacking",
checked = deleteAfterUnpack, checked = settingsPrefs.deleteAfterUnpack,
onCheckedChange = { onCheckedChange = {
scope.launch { settingsViewModel.setDeleteAfterUnpack(it)
prefs.setDeleteAfterUnpack(it)
}
}, },
) )
Box( Box(
@@ -618,7 +747,7 @@ fun OnBoardingSetupSettings(
text = "Download Directory", text = "Download Directory",
) )
Text( Text(
text = Uri.decode(downloadDir).ifEmpty { "None" }, text = Uri.decode(settingsPrefs.downloadDir).ifEmpty { "None" },
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
} }
@@ -633,7 +762,7 @@ fun OnBoardingSetupSettings(
text = "Unpack Directory", text = "Unpack Directory",
) )
Text( Text(
text = Uri.decode(unpackDir).ifEmpty { "None" }, text = Uri.decode(settingsPrefs.unpackDir).ifEmpty { "None" },
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
) )
} }
@@ -648,23 +777,18 @@ fun OnBoardingSetupSettings(
) )
Toggleable( Toggleable(
title = "Automatically decrypt downloaded content", title = "Automatically decrypt downloaded content",
checked = autoDecrypt, checked = settingsPrefs.pkg2zipAutoDecrypt,
onCheckedChange = { onCheckedChange = {
scope.launch { settingsViewModel.setPkg2zipAutoDecrypt(it)
prefs.setPkg2zipAutoDecrypt(it)
}
}, },
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
value = pkg2zipArgs, value = settingsPrefs.pkg2zipParams,
onValueChange = { onValueChange = {
pkg2zipArgs = it settingsViewModel.setPkg2zipParams(it)
scope.launch {
prefs.setPkg2zipParams(it)
}
}, // TODO: validate args are legal ?? }, // TODO: validate args are legal ??
label = { Text(text = "Pkg2zip args") }, label = { Text(text = "Pkg2zip args") },
placeholder = { Text(text = "-x {pkgFile} \"{zRifKey}\"") }, placeholder = { Text(text = "-x {pkgFile} \"{zRifKey}\"") },
@@ -679,7 +803,9 @@ fun OnBoardingSetupSettings(
incrementPage = incrementPage, incrementPage = incrementPage,
decrementPage = decrementPage, decrementPage = decrementPage,
skipToEnd = skipToEnd, skipToEnd = skipToEnd,
navigateToHome = navigateToHome navigateToHome = navigateToHome,
settingsViewModel = settingsViewModel,
onboardingViewModel = onboardingViewModel,
) )
} }
} }
@@ -714,17 +840,18 @@ fun OnBoardingBottomButtons(
decrementPage: () -> Unit = {}, decrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {}, skipToEnd: () -> Unit = {},
navigateToHome: () -> Unit = {}, navigateToHome: () -> Unit = {},
settingsViewModel: SettingsViewModel? = null,
onboardingViewModel: OnboardingViewModel? = null
) { ) {
val prefs = Preferences(LocalContext.current) val finalPage = PAGE_COUNT - 1
val scope = rememberCoroutineScope()
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.1f), .fillMaxHeight(0.1f),
horizontalArrangement = if (page != 2) Arrangement.SpaceBetween else Arrangement.End, horizontalArrangement = if (page != finalPage) Arrangement.SpaceBetween else Arrangement.End,
) { ) {
if (page == 1) { if (page != finalPage) {
FilledTonalButton( FilledTonalButton(
onClick = skipToEnd, onClick = skipToEnd,
colors = ButtonDefaults.filledTonalButtonColors( colors = ButtonDefaults.filledTonalButtonColors(
@@ -741,13 +868,11 @@ fun OnBoardingBottomButtons(
Text(text = "Prev") Text(text = "Prev")
} }
} }
if (page == 2) { if (page == finalPage) {
Button( Button(
onClick = { onClick = {
navigateToHome() navigateToHome()
scope.launch { onboardingViewModel?.finishOnboarding(settingsViewModel!!.shouldCreateDefaultDir())
prefs.finishOnboarding()
}
}, },
contentPadding = PaddingValues(horizontal = 18.dp) contentPadding = PaddingValues(horizontal = 18.dp)
) { ) {
@@ -1,5 +1,6 @@
package com.illusionware.npsbrowser.ui.pages package com.illusionware.npsbrowser.ui.pages
import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -22,16 +23,10 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.outlined.ArrowCircleDown
import androidx.compose.material.icons.outlined.ArrowDownward
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.ArrowOutward
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.material.icons.outlined.CompareArrows
import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.SubdirectoryArrowRight
import androidx.compose.material.icons.outlined.Unarchive import androidx.compose.material.icons.outlined.Unarchive
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -42,11 +37,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -59,28 +52,37 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.illusionware.npsbrowser.ConsoleType import androidx.compose.ui.unit.sp
import com.illusionware.npsbrowser.DataType import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.illusionware.npsbrowser.Preferences
import com.illusionware.npsbrowser.R import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.ui.components.NPSAlertDialog import com.illusionware.npsbrowser.ui.components.NPSAlertDialog
import com.illusionware.npsbrowser.ui.components.NPSIconButton import com.illusionware.npsbrowser.ui.components.NPSIconButton
import com.illusionware.npsbrowser.ui.components.NPSRadioButton import com.illusionware.npsbrowser.ui.components.NPSRadioButton
import com.illusionware.npsbrowser.ui.theme.ColorSecondaryLight import com.illusionware.npsbrowser.ui.theme.ColorSecondaryLight
import com.illusionware.npsbrowser.ui.theme.Typography import com.illusionware.npsbrowser.ui.theme.Typography
import kotlinx.coroutines.CoroutineScope import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.Dispatchers import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.data.Theme
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
val Themes = hashMapOf( private val Themes = hashMapOf(
0 to "Light", Theme.LIGHT.ordinal to "Light",
1 to "Dark", Theme.DARK.ordinal to "Dark",
2 to "Use System Theme" Theme.SYSTEM.ordinal to "Use System Theme"
) )
@Composable @Composable
@Preview @Preview
fun SettingsScreen(navigationGoBack: () -> Unit = {}, onThemeChange: (v: Int) -> Unit = {}) { fun SettingsScreen(
navigationGoBack: () -> Unit = {},
viewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
)
) {
val prefs = viewModel.uiState.collectAsStateWithLifecycle().value
Scaffold ( Scaffold (
topBar = { topBar = {
Row( Row(
@@ -102,11 +104,11 @@ fun SettingsScreen(navigationGoBack: () -> Unit = {}, onThemeChange: (v: Int) ->
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.padding(vertical = 16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.padding(vertical = 16.dp)) {
Appearance(onThemeChange) Appearance(viewModel, prefs)
TsvFiles() TsvFiles(viewModel, prefs)
Updates() Updates(viewModel, prefs)
Downloads() Downloads(viewModel, prefs)
Pkg2Zip() Pkg2Zip(viewModel, prefs)
} }
} }
@@ -114,83 +116,63 @@ fun SettingsScreen(navigationGoBack: () -> Unit = {}, onThemeChange: (v: Int) ->
} }
@Composable @Composable
fun Appearance(onThemeChange: (v: Int) -> Unit) { fun Appearance(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val prefs = Preferences(LocalContext.current)
val theme = prefs.theme.collectAsState(initial = 0)
var themeOpenDialog by remember { mutableStateOf(false) } var themeOpenDialog by remember { mutableStateOf(false) }
SettingGroup(title = stringResource(id = R.string.appearance)) { SettingGroup(title = stringResource(id = R.string.appearance)) {
DialogSetting( DialogSetting(
title = stringResource(id = R.string.theme), title = stringResource(id = R.string.theme),
value = Themes[theme.value] ?: "None", value = Themes[prefs.appTheme] ?: "None",
icon = Icons.Outlined.Palette, icon = Icons.Outlined.Palette,
onClick = { themeOpenDialog = true } onClick = { themeOpenDialog = true }
) )
} }
if (themeOpenDialog) { if (themeOpenDialog) {
ThemeDialog( ThemeDialog(
prefs = prefs,
onDismiss = { themeOpenDialog = false }, onDismiss = { themeOpenDialog = false },
onThemeChange = onThemeChange onThemeChange = {
viewModel.setTheme(it)
}
) )
} }
} }
@Composable @Composable
fun TsvFiles() { fun TsvFiles(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val psvGames = prefs.psvGames.collectAsState(initial = "").value.ifEmpty { "Browse" }
val psvDlc = prefs.psvDLC.collectAsState(initial = "").value.ifEmpty { "Browse" }
val psvThemes = prefs.psvThemes.collectAsState(initial = "").value.ifEmpty { "Browse" }
val ps3Games = prefs.ps3Games.collectAsState(initial = "").value.ifEmpty { "Browse" }
val ps3Dlc = prefs.ps3DLC.collectAsState(initial = "").value.ifEmpty { "Browse" }
val pspGames = prefs.pspGames.collectAsState(initial = "").value.ifEmpty { "Browse" }
val pspDlc = prefs.pspDLC.collectAsState(initial = "").value.ifEmpty { "Browse" }
val psxGames = prefs.psxGames.collectAsState(initial = "").value.ifEmpty { "Browse" }
val psmGames = prefs.psmGames.collectAsState(initial = "").value.ifEmpty { "Browse" }
SettingGroup(title = "TSV Files") { SettingGroup(title = "TSV Files") {
TSVPicker( TSVPicker(
title = "PSV Games", title = "PSV Games",
value = Uri.decode(psvGames), value = Uri.decode(prefs.psvGames.ifEmpty { "Browse" }),
icon = painterResource(id = R.drawable.vita_icon), icon = painterResource(id = R.drawable.vita_icon),
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it)
prefs.setTSVFile(ConsoleType.PSVITA, DataType.GAMES, it)
}
} }
) )
TSVPicker( TSVPicker(
title = "PSV DLC", title = "PSV DLC",
value = Uri.decode(psvDlc), value = Uri.decode(prefs.psvDlc.ifEmpty { "Browse" }),
icon = null, icon = null,
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it)
prefs.setTSVFile(ConsoleType.PSVITA, DataType.DLC, it)
}
} }
) )
TSVPicker( TSVPicker(
title = "PSV Themes", title = "PSV Themes",
value = Uri.decode(psvThemes), value = Uri.decode(prefs.psvThemes.ifEmpty { "Browse" }),
icon = null, icon = null,
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it)
prefs.setTSVFile(ConsoleType.PSVITA, DataType.THEMES, it)
}
}, },
) )
TSVPicker( TSVPicker(
title = "PS3 Games", title = "PS3 Games",
value = Uri.decode(ps3Games), value = Uri.decode(prefs.ps3Games).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.ps3_icon), icon = painterResource(id = R.drawable.ps3_icon),
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it)
prefs.setTSVFile(ConsoleType.PS3, DataType.GAMES, it)
}
} }
) )
if (!expanded) { if (!expanded) {
@@ -209,52 +191,42 @@ fun TsvFiles() {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
TSVPicker( TSVPicker(
title = "PS3 DLC", title = "PS3 DLC",
value = Uri.decode(ps3Dlc), value = Uri.decode(prefs.ps3Dlc).ifEmpty { "Browse" },
icon = null, icon = null,
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.DLC, it)
prefs.setTSVFile(ConsoleType.PS3, DataType.DLC, it)
}
} }
) )
TSVPicker( TSVPicker(
title = "PSP Games", title = "PSP Games",
value = Uri.decode(pspGames), value = Uri.decode(prefs.pspGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psp_icon), icon = painterResource(id = R.drawable.psp_icon),
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it)
prefs.setTSVFile(ConsoleType.PSP, DataType.GAMES, it)
}
} }
) )
TSVPicker( TSVPicker(
title = "PSP DLC", title = "PSP DLC",
value = Uri.decode(pspDlc), value = Uri.decode(prefs.pspDlc).ifEmpty { "Browse" },
icon = null, icon = null,
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.DLC, it)
prefs.setTSVFile(ConsoleType.PSP, DataType.DLC, it)
}
} }
) )
TSVPicker( TSVPicker(
title = "PSX Games", title = "PSX Games",
value = Uri.decode(psxGames), value = Uri.decode(prefs.psxGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psx_icon), icon = painterResource(id = R.drawable.psx_icon),
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it)
prefs.setTSVFile(ConsoleType.PSX, DataType.GAMES, it)
}
} }
) )
TSVPicker( TSVPicker(
title = "PSM Games", title = "PSM Games",
value = Uri.decode(psmGames), value = Uri.decode(prefs.psmGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psm_icon), icon = painterResource(id = R.drawable.psm_icon),
onFilePick = { onFilePick = {
scope.launch(Dispatchers.IO) { viewModel.setTSVFile(ConsoleType.PSM, PackageItemType.GAME, it)
prefs.setTSVFile(ConsoleType.PSM, DataType.GAMES, it)
}
} }
) )
} }
@@ -269,10 +241,16 @@ fun TSVPicker(
icon: Painter? = null, icon: Painter? = null,
onFilePick: (Uri) -> Unit, onFilePick: (Uri) -> Unit,
) { ) {
val context = LocalContext.current
val filePicker = rememberLauncherForActivityResult( val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.OpenDocument(),
) { fileUri -> ) { fileUri ->
if (fileUri != null) { if (fileUri != null) {
context.contentResolver.takePersistableUriPermission(
fileUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
onFilePick(fileUri) onFilePick(fileUri)
} }
} }
@@ -282,28 +260,25 @@ fun TSVPicker(
value = value, value = value,
icon = icon, icon = icon,
onClick = { onClick = {
filePicker.launch("text/tab-separated-values") filePicker.launch(arrayOf("text/tab-separated-values"))
} }
)} )}
@Composable @Composable
fun Updates() { fun Updates(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val hmacKey = prefs.hmacKey.collectAsState(initial = "").value
var hmacOpenDialog by remember { mutableStateOf(false) } var hmacOpenDialog by remember { mutableStateOf(false) }
var dialogHmacKey by remember { mutableStateOf("") } var dialogHmacKey by remember { mutableStateOf("") }
SideEffect { SideEffect {
dialogHmacKey = hmacKey dialogHmacKey = prefs.hmacKey
} }
SettingGroup(title = "Updates") { SettingGroup(title = "Updates") {
DialogSetting( DialogSetting(
title = "HMAC Key", title = "HMAC Key",
value = hmacKey.ifEmpty { "None" }, value = prefs.hmacKey.ifEmpty { "None" },
icon = Icons.Outlined.Key, icon = Icons.Outlined.Key,
onClick = { hmacOpenDialog = true } onClick = { hmacOpenDialog = true }
) )
@@ -317,9 +292,7 @@ fun Updates() {
Text(text = "Cancel") Text(text = "Cancel")
} }
TextButton(onClick = { TextButton(onClick = {
scope.launch { viewModel.setHMACKey(dialogHmacKey)
prefs.setHMACKey(dialogHmacKey)
}
hmacOpenDialog = false hmacOpenDialog = false
}) { }) {
Text(text = "OK") Text(text = "OK")
@@ -349,21 +322,12 @@ fun Updates() {
} }
@Composable @Composable
fun Downloads() { fun Downloads(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
val downloadDir = prefs.downloadDir.collectAsState(initial = "").value
val unpackDir = prefs.unpackDir.collectAsState(initial = "").value
val unpackInDownload = prefs.unpackInDownload.collectAsState(initial = false).value
val deleteAfterUnpack = prefs.deleteAfterUnpack.collectAsState(initial = false).value
val downloadDirPicker = rememberLauncherForActivityResult( val downloadDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(), contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri -> ) { dirUri ->
if (dirUri != null) { if (dirUri != null) {
scope.launch { viewModel.setDownloadDir(dirUri.toString())
prefs.setDownloadDir(dirUri.toString())
}
} }
} }
@@ -371,34 +335,28 @@ fun Downloads() {
contract = ActivityResultContracts.OpenDocumentTree(), contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri -> ) { dirUri ->
if (dirUri != null) { if (dirUri != null) {
scope.launch { viewModel.setUnpackDir(dirUri.toString())
prefs.setUnpackDir(dirUri.toString())
}
} }
} }
SettingGroup(title = "Downloads") { SettingGroup(title = "Downloads") {
ToggleSetting( ToggleSetting(
title = "Unpack in Download Directory", title = "Unpack in Download Directory",
checked = unpackInDownload, checked = prefs.unpackInDownload,
onClick = { onClick = {
scope.launch { viewModel.setUnpackInDownload(it)
prefs.setUnpackInDownload(it)
}
} }
) )
ToggleSetting( ToggleSetting(
title = "Delete package after unpacking", title = "Delete package after unpacking",
checked = deleteAfterUnpack, checked = prefs.deleteAfterUnpack,
onClick = { onClick = {
scope.launch { viewModel.setDeleteAfterUnpack(it)
prefs.setDeleteAfterUnpack(it)
}
} }
) )
DialogSetting( DialogSetting(
title = "Download Directory", title = "Download Directory",
value = Uri.decode(downloadDir), value = Uri.decode(prefs.downloadDir).ifEmpty { "None" },
icon = Icons.Outlined.FileDownload, icon = Icons.Outlined.FileDownload,
onClick = { onClick = {
downloadDirPicker.launch(Uri.EMPTY) downloadDirPicker.launch(Uri.EMPTY)
@@ -406,7 +364,7 @@ fun Downloads() {
) )
DialogSetting( DialogSetting(
title = "Unpack Directory", title = "Unpack Directory",
value = Uri.decode(unpackDir), value = Uri.decode(prefs.unpackDir).ifEmpty { "None" },
icon = Icons.Outlined.Unarchive, icon = Icons.Outlined.Unarchive,
onClick = { onClick = {
unpackDirPicker.launch(Uri.EMPTY) unpackDirPicker.launch(Uri.EMPTY)
@@ -416,32 +374,25 @@ fun Downloads() {
} }
@Composable @Composable
fun Pkg2Zip() { fun Pkg2Zip(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
val autoDecrypt = prefs.pkg2zipAutoDecrypt.collectAsState(initial = true).value
val pkg2zipParams = prefs.pkg2zipParams.collectAsState(initial = "").value
var pkg2zipOpenDialog by remember { mutableStateOf(false) } var pkg2zipOpenDialog by remember { mutableStateOf(false) }
var dialogPkg2zipParams by remember { mutableStateOf("") } var dialogPkg2zipParams by remember { mutableStateOf("") }
SideEffect { SideEffect {
dialogPkg2zipParams = pkg2zipParams dialogPkg2zipParams = prefs.pkg2zipParams
} }
SettingGroup(title = "Pkg2Zip") { SettingGroup(title = "Pkg2Zip") {
ToggleSetting( ToggleSetting(
title = "Automatically decrypt downloaded content", title = "Automatically decrypt downloaded content",
checked = autoDecrypt, checked = prefs.pkg2zipAutoDecrypt,
onClick = { onClick = {
scope.launch { viewModel.setPkg2zipAutoDecrypt(it)
prefs.setPkg2zipAutoDecrypt(it)
}
} }
) )
DialogSetting( DialogSetting(
title = "Decryption params", title = "Decryption params",
value = pkg2zipParams.ifEmpty { "None" }, value = prefs.pkg2zipParams.ifEmpty { "None" },
icon = painterResource( icon = painterResource(
id = R.drawable.lock_open id = R.drawable.lock_open
), ),
@@ -457,9 +408,7 @@ fun Pkg2Zip() {
Text(text = "Cancel") Text(text = "Cancel")
} }
TextButton(onClick = { TextButton(onClick = {
scope.launch { viewModel.setPkg2zipParams(dialogPkg2zipParams)
prefs.setPkg2zipParams(dialogPkg2zipParams)
}
pkg2zipOpenDialog = false pkg2zipOpenDialog = false
}) { }) {
Text(text = "OK") Text(text = "OK")
@@ -478,10 +427,11 @@ fun Pkg2Zip() {
} }
@Composable @Composable
fun ThemeDialog(onDismiss: () -> Unit, onThemeChange: (v: Int) -> Unit) { fun ThemeDialog(
val prefs = Preferences(LocalContext.current) prefs: SettingsPreferences,
val theme = prefs.theme.collectAsState(initial = "") onDismiss: () -> Unit,
onThemeChange: (v: Theme) -> Unit
) {
NPSAlertDialog( NPSAlertDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
title = stringResource(id = R.string.theme), title = stringResource(id = R.string.theme),
@@ -495,27 +445,24 @@ fun ThemeDialog(onDismiss: () -> Unit, onThemeChange: (v: Int) -> Unit) {
Column { Column {
NPSRadioButton( NPSRadioButton(
text = "Light", text = "Light",
onClick = { CoroutineScope(Dispatchers.IO).launch { onClick = {
onThemeChange(0) onThemeChange(Theme.LIGHT)
prefs.changeTheme(0) }
}, },
selected = theme.value == 0, selected = prefs.appTheme == Theme.LIGHT.ordinal,
) )
NPSRadioButton( NPSRadioButton(
text = "Dark", text = "Dark",
onClick = { CoroutineScope(Dispatchers.IO).launch { onClick = {
onThemeChange(1) onThemeChange(Theme.DARK)
prefs.changeTheme(1) }
}, },
selected = theme.value == 1, selected = prefs.appTheme == Theme.DARK.ordinal,
) )
NPSRadioButton( NPSRadioButton(
text = "Use System Theme", text = "Use System Theme",
onClick = { CoroutineScope(Dispatchers.IO).launch { onClick = {
onThemeChange(2) onThemeChange(Theme.SYSTEM)
prefs.changeTheme(2) }
}, },
selected = theme.value == 2, selected = prefs.appTheme == Theme.SYSTEM.ordinal,
) )
} }
} }
@@ -553,9 +500,9 @@ fun DialogSetting(title: String, value: String, icon: Painter?, onClick: () -> U
} else { } else {
Box(modifier = Modifier.width(56.dp)) Box(modifier = Modifier.width(56.dp))
} }
Column { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground) Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text(text = value, color = ColorSecondaryLight) Text(text = value, color = ColorSecondaryLight, fontSize = 14.sp)
} }
} }
} }
@@ -583,11 +530,12 @@ fun DialogSetting(
} else { } else {
Box(modifier = Modifier.width(56.dp)) Box(modifier = Modifier.width(56.dp))
} }
Column { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground) Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text( Text(
text = value, text = value,
color = ColorSecondaryLight, color = ColorSecondaryLight,
fontSize = 14.sp,
maxLines = if (singleLine) 1 else Int.MAX_VALUE, maxLines = if (singleLine) 1 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = 24.dp) modifier = Modifier.padding(end = 24.dp)
@@ -0,0 +1,52 @@
package com.illusionware.npsbrowser.viewmodels
import android.os.Environment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.data.OnboardingPreferences
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.File
class OnboardingViewModel(
private val onboardingPreferencesRepository: OnboardingPreferencesRepository
): ViewModel() {
val uiState: StateFlow<OnboardingPreferences> =
onboardingPreferencesRepository.getPreferences().stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(2_000),
OnboardingPreferences(
false,
)
)
fun finishOnboarding(shouldCreateDefaultDir: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
if (shouldCreateDefaultDir) {
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "NPSBrowser")
if (!dir.exists()) {
dir.mkdir()
}
}
onboardingPreferencesRepository.setSeenOnboarding(true)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
OnboardingViewModel(application.onboardingPreferencesRepository)
}
}
}
}
@@ -1,45 +1,152 @@
package com.illusionware.npsbrowser.viewmodels package com.illusionware.npsbrowser.viewmodels
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.github.doyaaaaaken.kotlincsv.dsl.context.ExcessFieldsRowBehaviour import com.github.doyaaaaaken.kotlincsv.dsl.context.ExcessFieldsRowBehaviour
import com.github.doyaaaaaken.kotlincsv.dsl.context.InsufficientFieldsRowBehaviour import com.github.doyaaaaaken.kotlincsv.dsl.context.InsufficientFieldsRowBehaviour
import com.github.doyaaaaaken.kotlincsv.dsl.csvReader import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
import com.illusionware.npsbrowser.ConsoleType import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.DataType import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItem
import com.illusionware.npsbrowser.model.PackageItemType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class Package( import java.io.InputStream
val id: String,
val title: String,
val region: String,
val minFW: String,
val pkgUrl: String,
val pkgSize: Int,
val consoleType: ConsoleType,
val dataType: DataType,
)
data class PackageListUiState( data class PackageListUiState(
val packages: List<Package> = emptyList(), val packages: ArrayList<PackageItem> = ArrayList(),
val error: String = "", val error: String = "",
val isLoading: Boolean = true,
) )
class PackageListViewModel(): ViewModel() { class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesRepository): ViewModel() {
// TODO: actually properly think about and implement this
private val _uiState = MutableStateFlow(PackageListUiState()) private val _uiState = MutableStateFlow(PackageListUiState())
val uiState: StateFlow<PackageListUiState> = _uiState.asStateFlow() val uiState: StateFlow<PackageListUiState> = _uiState.asStateFlow()
private val tsvReader = csvReader { private val tsvReader = csvReader {
charset = "ISO_8859_1" charset = "UTF-8"
quoteChar = '"' quoteChar = '"'
delimiter = '\t' delimiter = '\t'
excessFieldsRowBehaviour = ExcessFieldsRowBehaviour.TRIM excessFieldsRowBehaviour = ExcessFieldsRowBehaviour.TRIM
insufficientFieldsRowBehaviour = InsufficientFieldsRowBehaviour.EMPTY_STRING insufficientFieldsRowBehaviour = InsufficientFieldsRowBehaviour.EMPTY_STRING
} }
fun loadPackagesFromTsv() { init {
val file = "psv_games.tsv" viewModelScope.launch(Dispatchers.IO) {
val rows = tsvReader.readAll(file) val list = ArrayList<PackageItem>()
settingsRepo.availableTsvs().forEach {
list += parsePackageFromTSV(it, app)
}
list.sortBy { it.name }
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
settingsRepo.tsvFiles().collect { tsv ->
_uiState.emit(PackageListUiState())
val alreadyExists = list.find { item ->
item.consoleType == tsv.second && item.dataType == tsv.third
}
if (alreadyExists != null) {
list.removeAll { item ->
item.consoleType == tsv.second && item.dataType == tsv.third
}
}
list.addAll(parsePackageFromTSV(tsv, app))
list.sortBy { it.name }
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
}
}
}
private fun parsePackageFromTSV(
it: Triple<String, ConsoleType, PackageItemType>, app: NPSApp
): ArrayList<PackageItem> {
val list = ArrayList<PackageItem>()
val fileUri = it.first
val console = it.second
val type = it.third
val file: InputStream?
try {
file = app.contentResolver.openInputStream(Uri.parse(fileUri))
} catch (e: Exception) {
throw e
}
val tsv = tsvReader.readAllWithHeader(file!!)
tsv.forEach { row ->
var size = ""
val tempSize = row["File Size"]
if (!tempSize.isNullOrEmpty()) {
size = bytesIntoHumanReadable(tempSize.toLong())
}
list.add(
PackageItem(
row["Title ID"] ?: "",
row["Region"] ?: "",
row["Name"] ?: "",
row["PKG direct link"] ?: "",
row["Content ID"] ?: "",
row["Last Modification Date"] ?: "",
size,
row["SHA256"] ?: "",
row["zRIF"] ?: "",
row["RAP"] ?: "",
row["Required FW"] ?: "",
console,
type,
)
)
}
file.close()
return list
}
private fun bytesIntoHumanReadable(bytes: Long): String {
val kilobyte: Long = 1024
val megabyte = kilobyte * 1024
val gigabyte = megabyte * 1024
val terabyte = gigabyte * 1024
return if (bytes in 0 until kilobyte) {
"$bytes" + "B"
} else if (bytes in kilobyte until megabyte) {
String.format("%.2f", (bytes.toDouble() / kilobyte.toDouble())) + "KB"
} else if (bytes in megabyte until gigabyte) {
String.format("%.2f", (bytes.toDouble() / megabyte.toDouble())) + "MB"
} else if (bytes in gigabyte until terabyte) {
String.format("%.2f", (bytes.toDouble() / gigabyte.toDouble())) + "GB"
} else if (bytes >= terabyte) {
String.format("%.2f", (bytes.toDouble() / terabyte.toDouble())) + "TB"
} else {
"$bytes" + "B"
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
PackageListViewModel(application, application.settingsPreferencesRepository)
}
}
} }
} }
@@ -0,0 +1,138 @@
package com.illusionware.npsbrowser.viewmodels
import android.net.Uri
import android.os.Environment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.illusionware.npsbrowser.NPSApp
import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
import com.illusionware.npsbrowser.data.Theme
import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItemType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() {
val uiState: StateFlow<SettingsPreferences> =
settingsPrefsRepo.getPreferences().stateIn(viewModelScope, SharingStarted.Lazily, SettingsPreferences(
Theme.SYSTEM.ordinal,
ItemLayout.LIST.ordinal,
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
false,
false,
"",
true,
))
fun availableTsvs(): MutableList<Pair<ConsoleType, PackageItemType>> {
val list = mutableListOf<Pair<ConsoleType, PackageItemType>>()
if (uiState.value.psvGames.isNotEmpty()) list.add(Pair(ConsoleType.PSVITA, PackageItemType.GAME))
if (uiState.value.ps3Games.isNotEmpty()) list.add(Pair(ConsoleType.PS3, PackageItemType.GAME))
if (uiState.value.pspGames.isNotEmpty()) list.add(Pair(ConsoleType.PSP, PackageItemType.GAME))
if (uiState.value.psxGames.isNotEmpty()) list.add(Pair(ConsoleType.PSX, PackageItemType.GAME))
if (uiState.value.psmGames.isNotEmpty()) list.add(Pair(ConsoleType.PSM, PackageItemType.GAME))
return list
}
suspend fun getAppTheme(): Int {
return settingsPrefsRepo.getPreferences().first().appTheme
}
fun shouldCreateDefaultDir(): Boolean {
val defaultDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + "/NPSBrowser"
return uiState.value.downloadDir == defaultDir
|| uiState.value.unpackDir == defaultDir
}
fun setTSVFile(console: ConsoleType, itemType: PackageItemType, file: Uri) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setTSVFile(console, itemType, file)
}
}
fun setTheme(theme: Theme) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setTheme(theme)
}
}
fun setLayout(layout: ItemLayout) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setLayout(layout)
}
}
fun setHMACKey(hmacKey: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setHMACKey(hmacKey)
}
}
fun setDownloadDir(downloadDir: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setDownloadDir(downloadDir)
}
}
fun setUnpackDir(unpackDir: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setUnpackDir(unpackDir)
}
}
fun setUnpackInDownload(unpackInDownload: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setUnpackInDownload(unpackInDownload)
}
}
fun setDeleteAfterUnpack(deleteAfterUnpack: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setDeleteAfterUnpack(deleteAfterUnpack)
}
}
fun setPkg2zipParams(pkg2zipParams: String) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setPkg2zipParams(pkg2zipParams)
}
}
fun setPkg2zipAutoDecrypt(pkg2zipAutoDecrypt: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
settingsPrefsRepo.setPkg2zipAutoDecrypt(pkg2zipAutoDecrypt)
}
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = (this[APPLICATION_KEY] as NPSApp)
SettingsViewModel(application.settingsPreferencesRepository)
}
}
}
}
@@ -0,0 +1,20 @@
<vector android:height="256dp" android:viewportHeight="100"
android:viewportWidth="100" android:width="256dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#ee3e54" android:pathData="M13,27A2,2 0,1 0,13 31A2,2 0,1 0,13 27Z"/>
<path android:fillColor="#f1bc19" android:pathData="M77,12A1,1 0,1 0,77 14A1,1 0,1 0,77 12Z"/>
<path android:fillColor="#fce0a2" android:pathData="M50,13A37,37 0,1 0,50 87A37,37 0,1 0,50 13Z"/>
<path android:fillColor="#f1bc19" android:pathData="M83,11A4,4 0,1 0,83 19A4,4 0,1 0,83 11Z"/>
<path android:fillColor="#ee3e54" android:pathData="M87,22A2,2 0,1 0,87 26A2,2 0,1 0,87 22Z"/>
<path android:fillColor="#fbcd59" android:pathData="M81,74A2,2 0,1 0,81 78,2 2,0 1,0 81,74zM15,59A4,4 0,1 0,15 67,4 4,0 1,0 15,59z"/>
<path android:fillColor="#ee3e54" android:pathData="M25,85A2,2 0,1 0,25 89A2,2 0,1 0,25 85Z"/>
<path android:fillColor="#fff" android:pathData="M18.5,51A2.5,2.5 0,1 0,18.5 56A2.5,2.5 0,1 0,18.5 51Z"/>
<path android:fillColor="#f1bc19" android:pathData="M21,66A1,1 0,1 0,21 68A1,1 0,1 0,21 66Z"/>
<path android:fillColor="#fff" android:pathData="M80,33A1,1 0,1 0,80 35A1,1 0,1 0,80 33Z"/>
<path android:fillColor="#cdcbbd" android:pathData="M29.51,69.3c-0.5,0 -1.13,-0.14 -1.48,-1.27c-0.27,-0.87 -0.27,-1.95 -0.27,-2.65L27.7,37.8v-3.82c0,-1.69 1.32,-3.07 2.94,-3.07h17.65c1.66,0 3.01,1.46 3.01,3.24v1.46c0,1.24 0.97,2.26 2.16,2.26h16.09c0.42,0 0.76,0.38 0.76,0.86v23.57c0,0.33 0.01,0.75 0.03,1.21c0.05,1.7 0.13,4.46 -0.44,5.8H29.51z"/>
<path android:fillColor="#472b29" android:pathData="M48.29,31.6c1.27,0 2.31,1.14 2.31,2.54v1.46c0,1.63 1.28,2.96 2.86,2.96l16.08,-0c0.01,0 0.07,0.05 0.07,0.16v23.57c0,0.34 0.01,0.76 0.03,1.23c0.04,1.29 0.11,3.74 -0.24,5.08h-39.88c-0.27,0 -0.57,0 -0.81,-0.79c-0.24,-0.77 -0.24,-1.78 -0.24,-2.44l0,-0.09L28.4,37.8v-3.82c0,-1.31 1.01,-2.37 2.24,-2.37H48.29M48.29,30.2H30.65c-2.01,0 -3.64,1.7 -3.64,3.77v3.82l0.05,27.48C27.05,66.7 27,70 29.51,70h40.8C71.37,68.36 71,64.1 71,62.28V38.72c0,-0.86 -0.65,-1.56 -1.46,-1.56H53.46c-0.81,0 -1.46,-0.7 -1.46,-1.56v-1.46C52,31.98 50.33,30.2 48.29,30.2L48.29,30.2z"/>
<path android:fillColor="#472b29" android:pathData="M41.5,35h-11c-0.28,0 -0.5,-0.22 -0.5,-0.5s0.22,-0.5 0.5,-0.5h11c0.28,0 0.5,0.22 0.5,0.5S41.78,35 41.5,35z"/>
<path android:fillColor="#472b29" android:pathData="M48.5,35h-4c-0.28,0 -0.5,-0.22 -0.5,-0.5s0.22,-0.5 0.5,-0.5h4c0.28,0 0.5,0.22 0.5,0.5S48.78,35 48.5,35z"/>
<path android:fillColor="#fdfcee" android:pathData="M29.48,69.3c-0.61,0 -1.11,-0.52 -1.14,-1.18L34.03,42.7h41.92l-5.64,25.37c-0.01,0.05 -0.02,0.1 -0.02,0.15c0,0.29 -0.11,0.57 -0.3,0.77c-0.18,0.19 -0.43,0.3 -0.68,0.3H29.48z"/>
<path android:fillColor="#472b29" android:pathData="M75.07,43.4l-5.45,24.52c-0.02,0.1 -0.03,0.2 -0.03,0.31c0,0.12 -0.04,0.23 -0.12,0.3c-0.05,0.05 -0.11,0.08 -0.17,0.08H29.48c-0.21,0 -0.39,-0.19 -0.43,-0.44L34.59,43.4H75.07M76.82,42H33.47l-5.83,26.05c0,1.08 0.82,1.95 1.84,1.95h39.83c0.93,0 1.68,-0.8 1.68,-1.78L76.82,42L76.82,42z"/>
<path android:fillColor="#472b29" android:pathData="M50.5,55h-14c-0.15,0 -0.3,-0.07 -0.39,-0.19s-0.13,-0.28 -0.09,-0.43l1.5,-6C37.57,48.16 37.77,48 38,48h14c0.15,0 0.3,0.07 0.39,0.19s0.13,0.28 0.09,0.43l-1.5,6C50.93,54.84 50.73,55 50.5,55zM37.14,54h12.97l1.25,-5H38.39L37.14,54z"/>
</vector>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
val kotlin_version = "1.8.10" val kotlin_version = "1.8.22"
repositories { repositories {
google() google()
@@ -20,6 +20,7 @@ allprojects {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven( url = "https://jitpack.io" )
} }
} }