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
+1
View File
@@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".NPSApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
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 androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.Search
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.Icon
import androidx.compose.material3.MaterialTheme
@@ -47,7 +48,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.FontFamily
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.composable
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
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.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.ColorOnPrimaryLight
import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.ui.components.*
import com.illusionware.npsbrowser.ui.pages.SettingsScreen
import com.illusionware.npsbrowser.ui.pages.OnBoarding
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 com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.PackageListViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.runBlocking
val PlaceholderColor = Color(0x1F888888)
@@ -102,59 +105,60 @@ val redHatDisplayFamily = FontFamily(
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
setContent {
Router()
}
}
}
val settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
)
val onboardingViewModel: OnboardingViewModel = viewModel(
factory = OnboardingViewModel.Factory
)
@Composable
fun Router() {
val prefs = Preferences(LocalContext.current)
var theme by remember { runBlocking { mutableStateOf(prefs.theme.first()) } }
var seenOnboarding by remember { runBlocking { mutableStateOf(prefs.seenOnboarding.first()) } }
val navController = rememberNavController()
val onboardingPrefs = onboardingViewModel.uiState.collectAsStateWithLifecycle().value
val appTheme = runBlocking { settingsViewModel.getAppTheme() }
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val navController = rememberNavController()
val darkTheme = when (theme) {
0 -> false
1 -> true
else -> isSystemInDarkTheme()
}
val darkTheme = when (appTheme) {
0 -> false
1 -> true
else -> isSystemInDarkTheme()
}
NPSBrowserTheme(darkTheme = darkTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = if (seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
) {
NavHost(
navController = navController,
startDestination = if (seenOnboarding) "Home" else "Onboarding",
) {
composable(route = "Home") {
HomePage(
navigateToSettings = { navController.navigate("Settings") }
)
}
composable(route = "Settings") {
SettingsScreen(
navigationGoBack = { navController.popBackStack() },
onThemeChange = { v -> theme = v },
)
}
composable(route = "Onboarding") {
OnBoarding(
navigateToHome = {
navController.popBackStack()
seenOnboarding = true
navController.navigate("Home")
},
darkTheme = darkTheme
)
NPSBrowserTheme(darkTheme = darkTheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = if (onboardingPrefs.seenOnboarding) MaterialTheme.colorScheme.background else ColorAccent
) {
NavHost(
navController = navController,
startDestination = if (onboardingPrefs.seenOnboarding) "Home" else "Onboarding",
) {
composable(route = "Home") {
HomePage(
navigateToSettings = { navController.navigate("Settings") },
settingsPrefs = settingsPrefs,
settingsViewModel = settingsViewModel,
)
}
composable(route = "Settings") {
SettingsScreen(
navigationGoBack = { navController.popBackStack() },
)
}
composable(route = "Onboarding") {
OnBoarding(
navigateToHome = {
navController.popBackStack()
navController.navigate("Home")
},
darkTheme = darkTheme,
)
}
}
}
}
}
@@ -163,17 +167,18 @@ fun Router() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun HomePage(navigateToSettings: () -> Unit = {}) {
val prefs = Preferences(LocalContext.current)
fun HomePage(
navigateToSettings: () -> Unit = {},
settingsPrefs: SettingsPreferences,
settingsViewModel: SettingsViewModel,
) {
var isSearchOpen by rememberSaveable { mutableStateOf(false)}
var searchQuery by rememberSaveable { mutableStateOf("")}
val searchBar = remember { FocusRequester() }
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) }
// val noTsvs by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -239,7 +244,7 @@ fun HomePage(navigateToSettings: () -> Unit = {}) {
tooltip = stringResource(id = R.string.layout),
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
))
}
@@ -267,16 +272,16 @@ fun HomePage(navigateToSettings: () -> Unit = {}) {
if (tsvs.isEmpty()) {
NoTsvs(navigateToSettings = navigateToSettings)
} else {
// when (selectedBarItem) {
// ConsoleType.PSVITA -> VitaPage(searchQuery = searchQuery)
// ConsoleType.PS3 -> PS3Page(searchQuery = searchQuery)
// }
PackageList(selectedBarItem)
PackageList(selectedBarItem, settingsPrefs = settingsPrefs)
}
}
if (layoutDialogOpen) {
LayoutDialog(onDismiss = { layoutDialogOpen = false })
LayoutDialog(
onDismiss = { layoutDialogOpen = false },
prefs = settingsPrefs,
viewModel = settingsViewModel,
)
}
}
}
@@ -368,54 +373,82 @@ fun NoTsvs(padding: PaddingValues = PaddingValues(0.dp), navigateToSettings: ()
}
@Composable
@Preview
fun PackageList(type: ConsoleType = ConsoleType.PSVITA) {
val prefs = Preferences(LocalContext.current)
val layout = prefs.layout.collectAsState(initial = Layout.LIST.ordinal).value
// val thing = when (type) {
// ConsoleType.PSVITA -> {
// var list = ArrayList<Package>()
// list.add(Package("test"))
// val gs = prefs.psvGames.collectAsState(initial = emptyList<Package>())
// list.add(gs.value)
// }
// ConsoleType.PS3 -> {
// prefs.ps3Games.collectAsState(initial = emptyList())
// }
// ConsoleType.PSP -> {
// prefs.pspGames.collectAsState(initial = emptyList())
// }
// ConsoleType.PSX -> {
// prefs.psxGames.collectAsState(initial = emptyList())
// }
// ConsoleType.PSM -> {
// prefs.psmGames.collectAsState(initial = emptyList())
// }
// }
fun PackageList(
type: ConsoleType = ConsoleType.PSVITA,
viewModel: PackageListViewModel = viewModel(
factory = PackageListViewModel.Factory
),
settingsPrefs: SettingsPreferences,
) {
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
val packages = when (type) {
ConsoleType.PSVITA -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSVITA }
}
ConsoleType.PS3 -> {
uiState.packages.filter { it.consoleType == ConsoleType.PS3 }
}
ConsoleType.PSP -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSP }
}
ConsoleType.PSX -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSX }
}
ConsoleType.PSM -> {
uiState.packages.filter { it.consoleType == ConsoleType.PSM }
}
}
Column(Modifier.fillMaxSize()) {
if (layout == Layout.LIST.ordinal) {
LazyColumn {
items(15) {
PackageListItem()
}
if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(140.dp),
contentPadding = PaddingValues(8.dp),
) {
items(15) {
PackageGridItem()
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 {
items(packages) {
PackageListItem(it)
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(140.dp),
contentPadding = PaddingValues(8.dp),
) {
items(packages) {
PackageGridItem(it)
}
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PackageListItem() {
fun PackageListItem(item: PackageItem) {
Box(
Modifier
.fillMaxWidth()
@@ -434,7 +467,7 @@ fun PackageListItem() {
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://picsum.photos/200")
.data("TODO:")
.crossfade(true)
.error(R.drawable.default_game_icon)
.build(),
@@ -451,7 +484,7 @@ fun PackageListItem() {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "10 Second Ninja X",
text = item.name,
color = MaterialTheme.colorScheme.onBackground,
style = Typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
@@ -466,25 +499,33 @@ fun PackageListItem() {
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = "US")
Text(text = "3.69")
Text(text = "223.8MB")
Tag(title = "GAME", small = true)
Text(text = item.region)
if (item.minFW.isNotEmpty()) {
Text(text = String.format("%.2f", item.minFW.toFloat()))
}
if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize)
}
Tag(title = item.dataType.name, small = true)
}
}
}
}
Column {
Icon(
painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A)
)
Icon(
painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No Zrif Key",
tint = Color(0xFFCB5A5A)
)
if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") {
Icon(
painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A)
)
}
if (item.consoleType != ConsoleType.PSX && (item.zRif.isEmpty() && item.rap.isEmpty())) {
Icon(
painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No License Key",
tint = Color(0xFFCB5A5A)
)
}
}
}
}
@@ -512,8 +553,7 @@ fun Tag(title: String, small: Boolean = false) {
}
@Composable
@Preview
fun PackageGridItem() {
fun PackageGridItem(item: PackageItem) {
Box(
Modifier
.fillMaxWidth()
@@ -529,7 +569,7 @@ fun PackageGridItem() {
content = {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://picsum.photos/200")
.data("TODO:")
.crossfade(true)
.error(R.drawable.default_game_icon)
.build(),
@@ -540,7 +580,7 @@ fun PackageGridItem() {
.fillMaxWidth(),
contentScale = ContentScale.Crop,
)
Tag(title = "GAME")
Tag(title = item.dataType.name)
}
) { measurables, constraints ->
val imagePlaceable = measurables[0].measure(constraints)
@@ -555,7 +595,7 @@ fun PackageGridItem() {
}
}
Text(
text = "10 Second Ninja X",
text = item.name,
color = MaterialTheme.colorScheme.onBackground,
style = Typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
@@ -566,32 +606,41 @@ fun PackageGridItem() {
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = "US")
Text(text = "3.69")
Text(text = "223.8MB")
Text(text = item.region)
if (item.minFW.isNotEmpty()) {
Text(text = String.format("%.2f", item.minFW.toFloat()))
}
if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize)
}
}
}
Row {
Icon(
painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A)
)
Icon(
painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No Zrif Key",
tint = Color(0xFFCB5A5A)
)
if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") {
Icon(
painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A)
)
}
if (item.zRif.isEmpty() && item.rap.isEmpty()) {
Icon(
painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No License Key",
tint = Color(0xFFCB5A5A)
)
}
}
}
}
}
@Composable
fun LayoutDialog(onDismiss: () -> Unit) {
val prefs = Preferences(LocalContext.current)
val layout = prefs.layout.collectAsState(initial = Layout.LIST.ordinal).value
fun LayoutDialog(
onDismiss: () -> Unit,
prefs: SettingsPreferences,
viewModel: SettingsViewModel
) {
NPSAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.layout),
@@ -604,13 +653,13 @@ fun LayoutDialog(onDismiss: () -> Unit) {
Column {
NPSRadioButton(
text = "List",
selected = layout == Layout.LIST.ordinal,
onClick = { CoroutineScope(Dispatchers.IO).launch { prefs.changeLayout(Layout.LIST.ordinal) } },
selected = prefs.layout == ItemLayout.LIST.ordinal,
onClick = { viewModel.setLayout(ItemLayout.LIST) },
)
NPSRadioButton(
text = "Grid",
selected = layout == Layout.GRID.ordinal,
onClick = { CoroutineScope(Dispatchers.IO).launch { prefs.changeLayout(Layout.GRID.ordinal) } },
selected = prefs.layout == ItemLayout.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.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -68,7 +69,6 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.text.TextRange
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.input.TextFieldValue
import androidx.compose.ui.unit.sp
import com.illusionware.npsbrowser.ConsoleType
import com.illusionware.npsbrowser.DataType
import com.illusionware.npsbrowser.Preferences
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.launch
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)
@SuppressLint("SourceLockedOrientationActivity")
@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 pagerState = rememberPagerState(
initialPage = 0,
@@ -99,13 +123,14 @@ fun OnBoarding(navigateToHome: () -> Unit = {}, darkTheme: Boolean = false) {
)
val scope = rememberCoroutineScope()
val context = LocalContext.current
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val darkIcons = when (pagerState.currentPage) {
0 -> false
else -> true
}
DisposableEffect(systemUiController, darkIcons) {
DisposableEffect(systemUiController) {
systemUiController.setStatusBarColor(
color = Color.Transparent,
darkIcons = darkIcons && !darkTheme
@@ -123,18 +148,48 @@ fun OnBoarding(navigateToHome: () -> Unit = {}, darkTheme: Boolean = false) {
HorizontalPager(pageCount = PAGE_COUNT, state = pagerState) { 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(
page = page,
incrementPage = { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } },
skipToEnd = { scope.launch { pagerState.animateScrollToPage(PAGE_COUNT - 1) } }
incrementPage = {
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(
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) } },
navigateToHome = navigateToHome
incrementPage = {
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
},
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
@Preview
fun OnBoardingSetupTSVs(
page: Int = 1,
incrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {}
skipToEnd: () -> Unit = {},
settingsViewModel: SettingsViewModel,
) {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
Scaffold { padding ->
Column(
Modifier
@@ -291,49 +343,49 @@ fun OnBoardingSetupTSVs(
TSVGroup(title = "PS Vita") {
TSVButton(
"Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSVITA, DataType.GAMES, it) }}
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it) }
)
TSVButton(
"Themes",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSVITA, DataType.THEMES, it) }}
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it) }
)
TSVButton(
"DLC",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSVITA, DataType.DLC, it) }}
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it) }
)
}
TSVGroup(title = "PSP") {
TSVButton(
"Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSP, DataType.GAMES, it) }}
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it) }
)
TSVButton(
"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))
}
TSVGroup(title = "PS3") {
TSVButton(
"Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PS3, DataType.GAMES, it) }}
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it) }
)
TSVButton(
"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))
}
TSVGroup(title = "PSX") {
TSVButton(
"Games",
onFinish = { scope.launch { prefs.setTSVFile(ConsoleType.PSX, DataType.GAMES, it) }}
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it) }
)
}
TSVGroup(title = "PSM") {
TSVButton(
"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
fun TSVButton(title: String, onFinish: (Uri) -> Unit) {
val context = LocalContext.current
var hasSelected by rememberSaveable { mutableStateOf(false) }
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
contract = ActivityResultContracts.OpenDocument(),
) { fileUri ->
if (fileUri != null) {
context.contentResolver.takePersistableUriPermission(
fileUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
onFinish(fileUri)
hasSelected = true
}
}
FilledTonalButton(
onClick = {
filePicker.launch("text/tab-separated-values")
filePicker.launch(arrayOf("text/tab-separated-values"))
},
modifier = Modifier
.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
@Preview
fun OnBoardingSetupSettings(
page: Int = 2,
incrementPage: () -> Unit = {},
decrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {},
navigateToHome: () -> Unit = {},
settingsViewModel: SettingsViewModel,
onboardingViewModel: OnboardingViewModel,
settingsPrefs: SettingsPreferences,
) {
val clipboard = LocalClipboardManager.current
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
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(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
scope.launch {
prefs.setDownloadDir(dirUri.toString())
}
settingsViewModel.setDownloadDir(dirUri.toString())
}
}
@@ -505,17 +642,13 @@ fun OnBoardingSetupSettings(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
scope.launch {
prefs.setUnpackDir(dirUri.toString())
}
settingsViewModel.setUnpackDir(dirUri.toString())
}
}
fun setHmacKey(key: String) {
if (key.isNotEmpty()) {
scope.launch {
prefs.setHMACKey(key)
}
settingsViewModel.setHMACKey(key)
}
}
@@ -592,20 +725,16 @@ fun OnBoardingSetupSettings(
Column {
Toggleable(
title = "Unpack in download directory",
checked = unpackInDownload,
checked = settingsPrefs.unpackInDownload,
onCheckedChange = {
scope.launch {
prefs.setUnpackInDownload(it)
}
settingsViewModel.setUnpackInDownload(it)
},
)
Toggleable(
title = "Delete package after unpacking",
checked = deleteAfterUnpack,
checked = settingsPrefs.deleteAfterUnpack,
onCheckedChange = {
scope.launch {
prefs.setDeleteAfterUnpack(it)
}
settingsViewModel.setDeleteAfterUnpack(it)
},
)
Box(
@@ -618,7 +747,7 @@ fun OnBoardingSetupSettings(
text = "Download Directory",
)
Text(
text = Uri.decode(downloadDir).ifEmpty { "None" },
text = Uri.decode(settingsPrefs.downloadDir).ifEmpty { "None" },
color = MaterialTheme.colorScheme.outline,
)
}
@@ -633,7 +762,7 @@ fun OnBoardingSetupSettings(
text = "Unpack Directory",
)
Text(
text = Uri.decode(unpackDir).ifEmpty { "None" },
text = Uri.decode(settingsPrefs.unpackDir).ifEmpty { "None" },
color = MaterialTheme.colorScheme.outline,
)
}
@@ -648,23 +777,18 @@ fun OnBoardingSetupSettings(
)
Toggleable(
title = "Automatically decrypt downloaded content",
checked = autoDecrypt,
checked = settingsPrefs.pkg2zipAutoDecrypt,
onCheckedChange = {
scope.launch {
prefs.setPkg2zipAutoDecrypt(it)
}
settingsViewModel.setPkg2zipAutoDecrypt(it)
},
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
value = pkg2zipArgs,
value = settingsPrefs.pkg2zipParams,
onValueChange = {
pkg2zipArgs = it
scope.launch {
prefs.setPkg2zipParams(it)
}
settingsViewModel.setPkg2zipParams(it)
}, // TODO: validate args are legal ??
label = { Text(text = "Pkg2zip args") },
placeholder = { Text(text = "-x {pkgFile} \"{zRifKey}\"") },
@@ -679,7 +803,9 @@ fun OnBoardingSetupSettings(
incrementPage = incrementPage,
decrementPage = decrementPage,
skipToEnd = skipToEnd,
navigateToHome = navigateToHome
navigateToHome = navigateToHome,
settingsViewModel = settingsViewModel,
onboardingViewModel = onboardingViewModel,
)
}
}
@@ -714,17 +840,18 @@ fun OnBoardingBottomButtons(
decrementPage: () -> Unit = {},
skipToEnd: () -> Unit = {},
navigateToHome: () -> Unit = {},
settingsViewModel: SettingsViewModel? = null,
onboardingViewModel: OnboardingViewModel? = null
) {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
val finalPage = PAGE_COUNT - 1
Row(
modifier = Modifier
.fillMaxWidth()
.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(
onClick = skipToEnd,
colors = ButtonDefaults.filledTonalButtonColors(
@@ -741,13 +868,11 @@ fun OnBoardingBottomButtons(
Text(text = "Prev")
}
}
if (page == 2) {
if (page == finalPage) {
Button(
onClick = {
navigateToHome()
scope.launch {
prefs.finishOnboarding()
}
onboardingViewModel?.finishOnboarding(settingsViewModel!!.shouldCreateDefaultDir())
},
contentPadding = PaddingValues(horizontal = 18.dp)
) {
@@ -1,5 +1,6 @@
package com.illusionware.npsbrowser.ui.pages
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
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.filled.ArrowBack
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.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.Key
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.SubdirectoryArrowRight
import androidx.compose.material.icons.outlined.Unarchive
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -42,11 +37,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -59,28 +52,37 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.illusionware.npsbrowser.ConsoleType
import com.illusionware.npsbrowser.DataType
import com.illusionware.npsbrowser.Preferences
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.ui.components.NPSAlertDialog
import com.illusionware.npsbrowser.ui.components.NPSIconButton
import com.illusionware.npsbrowser.ui.components.NPSRadioButton
import com.illusionware.npsbrowser.ui.theme.ColorSecondaryLight
import com.illusionware.npsbrowser.ui.theme.Typography
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
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(
0 to "Light",
1 to "Dark",
2 to "Use System Theme"
private val Themes = hashMapOf(
Theme.LIGHT.ordinal to "Light",
Theme.DARK.ordinal to "Dark",
Theme.SYSTEM.ordinal to "Use System Theme"
)
@Composable
@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 (
topBar = {
Row(
@@ -102,11 +104,11 @@ fun SettingsScreen(navigationGoBack: () -> Unit = {}, onThemeChange: (v: Int) ->
.verticalScroll(rememberScrollState())
) {
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.padding(vertical = 16.dp)) {
Appearance(onThemeChange)
TsvFiles()
Updates()
Downloads()
Pkg2Zip()
Appearance(viewModel, prefs)
TsvFiles(viewModel, prefs)
Updates(viewModel, prefs)
Downloads(viewModel, prefs)
Pkg2Zip(viewModel, prefs)
}
}
@@ -114,83 +116,63 @@ fun SettingsScreen(navigationGoBack: () -> Unit = {}, onThemeChange: (v: Int) ->
}
@Composable
fun Appearance(onThemeChange: (v: Int) -> Unit) {
val prefs = Preferences(LocalContext.current)
val theme = prefs.theme.collectAsState(initial = 0)
fun Appearance(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
var themeOpenDialog by remember { mutableStateOf(false) }
SettingGroup(title = stringResource(id = R.string.appearance)) {
DialogSetting(
title = stringResource(id = R.string.theme),
value = Themes[theme.value] ?: "None",
value = Themes[prefs.appTheme] ?: "None",
icon = Icons.Outlined.Palette,
onClick = { themeOpenDialog = true }
)
}
if (themeOpenDialog) {
ThemeDialog(
prefs = prefs,
onDismiss = { themeOpenDialog = false },
onThemeChange = onThemeChange
onThemeChange = {
viewModel.setTheme(it)
}
)
}
}
@Composable
fun TsvFiles() {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
fun TsvFiles(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
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") {
TSVPicker(
title = "PSV Games",
value = Uri.decode(psvGames),
value = Uri.decode(prefs.psvGames.ifEmpty { "Browse" }),
icon = painterResource(id = R.drawable.vita_icon),
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PSVITA, DataType.GAMES, it)
}
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it)
}
)
TSVPicker(
title = "PSV DLC",
value = Uri.decode(psvDlc),
value = Uri.decode(prefs.psvDlc.ifEmpty { "Browse" }),
icon = null,
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PSVITA, DataType.DLC, it)
}
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it)
}
)
TSVPicker(
title = "PSV Themes",
value = Uri.decode(psvThemes),
value = Uri.decode(prefs.psvThemes.ifEmpty { "Browse" }),
icon = null,
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PSVITA, DataType.THEMES, it)
}
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it)
},
)
TSVPicker(
title = "PS3 Games",
value = Uri.decode(ps3Games),
value = Uri.decode(prefs.ps3Games).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.ps3_icon),
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PS3, DataType.GAMES, it)
}
viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it)
}
)
if (!expanded) {
@@ -209,52 +191,42 @@ fun TsvFiles() {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
TSVPicker(
title = "PS3 DLC",
value = Uri.decode(ps3Dlc),
value = Uri.decode(prefs.ps3Dlc).ifEmpty { "Browse" },
icon = null,
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PS3, DataType.DLC, it)
}
viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.DLC, it)
}
)
TSVPicker(
title = "PSP Games",
value = Uri.decode(pspGames),
value = Uri.decode(prefs.pspGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psp_icon),
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PSP, DataType.GAMES, it)
}
viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it)
}
)
TSVPicker(
title = "PSP DLC",
value = Uri.decode(pspDlc),
value = Uri.decode(prefs.pspDlc).ifEmpty { "Browse" },
icon = null,
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PSP, DataType.DLC, it)
}
viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.DLC, it)
}
)
TSVPicker(
title = "PSX Games",
value = Uri.decode(psxGames),
value = Uri.decode(prefs.psxGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psx_icon),
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PSX, DataType.GAMES, it)
}
viewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it)
}
)
TSVPicker(
title = "PSM Games",
value = Uri.decode(psmGames),
value = Uri.decode(prefs.psmGames).ifEmpty { "Browse" },
icon = painterResource(id = R.drawable.psm_icon),
onFilePick = {
scope.launch(Dispatchers.IO) {
prefs.setTSVFile(ConsoleType.PSM, DataType.GAMES, it)
}
viewModel.setTSVFile(ConsoleType.PSM, PackageItemType.GAME, it)
}
)
}
@@ -269,10 +241,16 @@ fun TSVPicker(
icon: Painter? = null,
onFilePick: (Uri) -> Unit,
) {
val context = LocalContext.current
val filePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
contract = ActivityResultContracts.OpenDocument(),
) { fileUri ->
if (fileUri != null) {
context.contentResolver.takePersistableUriPermission(
fileUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
onFilePick(fileUri)
}
}
@@ -282,28 +260,25 @@ fun TSVPicker(
value = value,
icon = icon,
onClick = {
filePicker.launch("text/tab-separated-values")
filePicker.launch(arrayOf("text/tab-separated-values"))
}
)}
@Composable
fun Updates() {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
fun Updates(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val clipboard = LocalClipboardManager.current
val hmacKey = prefs.hmacKey.collectAsState(initial = "").value
var hmacOpenDialog by remember { mutableStateOf(false) }
var dialogHmacKey by remember { mutableStateOf("") }
SideEffect {
dialogHmacKey = hmacKey
dialogHmacKey = prefs.hmacKey
}
SettingGroup(title = "Updates") {
DialogSetting(
title = "HMAC Key",
value = hmacKey.ifEmpty { "None" },
value = prefs.hmacKey.ifEmpty { "None" },
icon = Icons.Outlined.Key,
onClick = { hmacOpenDialog = true }
)
@@ -317,9 +292,7 @@ fun Updates() {
Text(text = "Cancel")
}
TextButton(onClick = {
scope.launch {
prefs.setHMACKey(dialogHmacKey)
}
viewModel.setHMACKey(dialogHmacKey)
hmacOpenDialog = false
}) {
Text(text = "OK")
@@ -349,21 +322,12 @@ fun Updates() {
}
@Composable
fun Downloads() {
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
fun Downloads(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
val downloadDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
scope.launch {
prefs.setDownloadDir(dirUri.toString())
}
viewModel.setDownloadDir(dirUri.toString())
}
}
@@ -371,34 +335,28 @@ fun Downloads() {
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
scope.launch {
prefs.setUnpackDir(dirUri.toString())
}
viewModel.setUnpackDir(dirUri.toString())
}
}
SettingGroup(title = "Downloads") {
ToggleSetting(
title = "Unpack in Download Directory",
checked = unpackInDownload,
checked = prefs.unpackInDownload,
onClick = {
scope.launch {
prefs.setUnpackInDownload(it)
}
viewModel.setUnpackInDownload(it)
}
)
ToggleSetting(
title = "Delete package after unpacking",
checked = deleteAfterUnpack,
checked = prefs.deleteAfterUnpack,
onClick = {
scope.launch {
prefs.setDeleteAfterUnpack(it)
}
viewModel.setDeleteAfterUnpack(it)
}
)
DialogSetting(
title = "Download Directory",
value = Uri.decode(downloadDir),
value = Uri.decode(prefs.downloadDir).ifEmpty { "None" },
icon = Icons.Outlined.FileDownload,
onClick = {
downloadDirPicker.launch(Uri.EMPTY)
@@ -406,7 +364,7 @@ fun Downloads() {
)
DialogSetting(
title = "Unpack Directory",
value = Uri.decode(unpackDir),
value = Uri.decode(prefs.unpackDir).ifEmpty { "None" },
icon = Icons.Outlined.Unarchive,
onClick = {
unpackDirPicker.launch(Uri.EMPTY)
@@ -416,32 +374,25 @@ fun Downloads() {
}
@Composable
fun Pkg2Zip() {
val prefs = Preferences(LocalContext.current)
val scope = rememberCoroutineScope()
val autoDecrypt = prefs.pkg2zipAutoDecrypt.collectAsState(initial = true).value
val pkg2zipParams = prefs.pkg2zipParams.collectAsState(initial = "").value
fun Pkg2Zip(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
var pkg2zipOpenDialog by remember { mutableStateOf(false) }
var dialogPkg2zipParams by remember { mutableStateOf("") }
SideEffect {
dialogPkg2zipParams = pkg2zipParams
dialogPkg2zipParams = prefs.pkg2zipParams
}
SettingGroup(title = "Pkg2Zip") {
ToggleSetting(
title = "Automatically decrypt downloaded content",
checked = autoDecrypt,
checked = prefs.pkg2zipAutoDecrypt,
onClick = {
scope.launch {
prefs.setPkg2zipAutoDecrypt(it)
}
viewModel.setPkg2zipAutoDecrypt(it)
}
)
DialogSetting(
title = "Decryption params",
value = pkg2zipParams.ifEmpty { "None" },
value = prefs.pkg2zipParams.ifEmpty { "None" },
icon = painterResource(
id = R.drawable.lock_open
),
@@ -457,9 +408,7 @@ fun Pkg2Zip() {
Text(text = "Cancel")
}
TextButton(onClick = {
scope.launch {
prefs.setPkg2zipParams(dialogPkg2zipParams)
}
viewModel.setPkg2zipParams(dialogPkg2zipParams)
pkg2zipOpenDialog = false
}) {
Text(text = "OK")
@@ -478,10 +427,11 @@ fun Pkg2Zip() {
}
@Composable
fun ThemeDialog(onDismiss: () -> Unit, onThemeChange: (v: Int) -> Unit) {
val prefs = Preferences(LocalContext.current)
val theme = prefs.theme.collectAsState(initial = "")
fun ThemeDialog(
prefs: SettingsPreferences,
onDismiss: () -> Unit,
onThemeChange: (v: Theme) -> Unit
) {
NPSAlertDialog(
onDismiss = onDismiss,
title = stringResource(id = R.string.theme),
@@ -495,27 +445,24 @@ fun ThemeDialog(onDismiss: () -> Unit, onThemeChange: (v: Int) -> Unit) {
Column {
NPSRadioButton(
text = "Light",
onClick = { CoroutineScope(Dispatchers.IO).launch {
onThemeChange(0)
prefs.changeTheme(0) }
onClick = {
onThemeChange(Theme.LIGHT)
},
selected = theme.value == 0,
selected = prefs.appTheme == Theme.LIGHT.ordinal,
)
NPSRadioButton(
text = "Dark",
onClick = { CoroutineScope(Dispatchers.IO).launch {
onThemeChange(1)
prefs.changeTheme(1) }
onClick = {
onThemeChange(Theme.DARK)
},
selected = theme.value == 1,
selected = prefs.appTheme == Theme.DARK.ordinal,
)
NPSRadioButton(
text = "Use System Theme",
onClick = { CoroutineScope(Dispatchers.IO).launch {
onThemeChange(2)
prefs.changeTheme(2) }
onClick = {
onThemeChange(Theme.SYSTEM)
},
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 {
Box(modifier = Modifier.width(56.dp))
}
Column {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
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 {
Box(modifier = Modifier.width(56.dp))
}
Column {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text(
text = value,
color = ColorSecondaryLight,
fontSize = 14.sp,
maxLines = if (singleLine) 1 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
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
import android.net.Uri
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.InsufficientFieldsRowBehaviour
import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
import com.illusionware.npsbrowser.ConsoleType
import com.illusionware.npsbrowser.DataType
import com.illusionware.npsbrowser.NPSApp
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class Package(
val id: String,
val title: String,
val region: String,
val minFW: String,
val pkgUrl: String,
val pkgSize: Int,
val consoleType: ConsoleType,
val dataType: DataType,
)
import kotlinx.coroutines.launch
import java.io.InputStream
data class PackageListUiState(
val packages: List<Package> = emptyList(),
val packages: ArrayList<PackageItem> = ArrayList(),
val error: String = "",
val isLoading: Boolean = true,
)
class PackageListViewModel(): ViewModel() {
// TODO: actually properly think about and implement this
class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesRepository): ViewModel() {
private val _uiState = MutableStateFlow(PackageListUiState())
val uiState: StateFlow<PackageListUiState> = _uiState.asStateFlow()
private val tsvReader = csvReader {
charset = "ISO_8859_1"
charset = "UTF-8"
quoteChar = '"'
delimiter = '\t'
excessFieldsRowBehaviour = ExcessFieldsRowBehaviour.TRIM
insufficientFieldsRowBehaviour = InsufficientFieldsRowBehaviour.EMPTY_STRING
}
fun loadPackagesFromTsv() {
val file = "psv_games.tsv"
val rows = tsvReader.readAll(file)
init {
viewModelScope.launch(Dispatchers.IO) {
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.