diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cea2e01..dd9cc31 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,7 +61,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.4.4" + kotlinCompilerExtensionVersion = "1.4.8" } buildToolsVersion = "33.0.1" kotlinOptions { @@ -76,10 +76,12 @@ repositories { dependencies { 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(platform("androidx.compose:compose-bom:2022.10.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.core:core-ktx:1.10.1") androidTestImplementation(platform("androidx.compose:compose-bom:2022.10.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") // Jetpack Compose @@ -88,7 +90,7 @@ dependencies { androidTestImplementation(composeBom) // 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") // Android Studio Preview support implementation("androidx.compose.ui:ui-tooling-preview") @@ -100,7 +102,7 @@ dependencies { // Compose extras library for controlling system ui and etc. 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") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2dc2646..e4b0650 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/Preferences.kt b/app/src/main/java/com/illusionware/npsbrowser/Preferences.kt deleted file mode 100644 index 1dc03b3..0000000 --- a/app/src/main/java/com/illusionware/npsbrowser/Preferences.kt +++ /dev/null @@ -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> { - val list = mutableListOf>() - 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 - } - } -} diff --git a/app/src/main/java/com/illusionware/npsbrowser/activities/MainActivity.kt b/app/src/main/java/com/illusionware/npsbrowser/activities/MainActivity.kt index e4762da..51205a4 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/activities/MainActivity.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/activities/MainActivity.kt @@ -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() -// list.add(Package("test")) -// val gs = prefs.psvGames.collectAsState(initial = emptyList()) -// 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) }, ) } } diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/OnboardingPreferencesRepository.kt b/app/src/main/java/com/illusionware/npsbrowser/data/OnboardingPreferencesRepository.kt new file mode 100644 index 0000000..0d53288 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/OnboardingPreferencesRepository.kt @@ -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, +) { + companion object { + private val SEEN_ONBOARDING = booleanPreferencesKey("seen_onboarding") + } + + fun getPreferences(): Flow = + 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/data/SettingsPreferencesRepository.kt b/app/src/main/java/com/illusionware/npsbrowser/data/SettingsPreferencesRepository.kt new file mode 100644 index 0000000..82d5b66 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/data/SettingsPreferencesRepository.kt @@ -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 +) { + 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>() + + fun getPreferences(): Flow = + dataStore.data.map { mapSettingsPreferences(it) } + + suspend fun availableTsvs(): MutableList> { + val list = mutableListOf>() + + 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/model/ConsoleType.kt b/app/src/main/java/com/illusionware/npsbrowser/model/ConsoleType.kt new file mode 100644 index 0000000..6b3085a --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/model/ConsoleType.kt @@ -0,0 +1,9 @@ +package com.illusionware.npsbrowser.model + +enum class ConsoleType { + PSVITA, + PS3, + PSP, + PSX, + PSM, +} diff --git a/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt new file mode 100644 index 0000000..1910ad2 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/model/PackageItemType.kt b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItemType.kt new file mode 100644 index 0000000..20b7f8b --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItemType.kt @@ -0,0 +1,7 @@ +package com.illusionware.npsbrowser.model + +enum class PackageItemType { + GAME, + DLC, + THEME, +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Onboarding.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Onboarding.kt index 3097b80..20d7062 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Onboarding.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Onboarding.kt @@ -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) ) { diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Settings.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Settings.kt index 88e685d..cec5d6f 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Settings.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/pages/Settings.kt @@ -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) diff --git a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/OnboardingViewModel.kt b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/OnboardingViewModel.kt new file mode 100644 index 0000000..468d20c --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/OnboardingViewModel.kt @@ -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 = + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt index 09885dc..c68db0c 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt @@ -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 = emptyList(), + val packages: ArrayList = 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 = _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() + + 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, app: NPSApp + ): ArrayList { + val list = ArrayList() + + 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) + } + } } } diff --git a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/SettingsViewModel.kt b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/SettingsViewModel.kt new file mode 100644 index 0000000..59d43dd --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/SettingsViewModel.kt @@ -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 = + settingsPrefsRepo.getPreferences().stateIn(viewModelScope, SharingStarted.Lazily, SettingsPreferences( + Theme.SYSTEM.ordinal, + ItemLayout.LIST.ordinal, + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + false, + false, + "", + true, + )) + + fun availableTsvs(): MutableList> { + val list = mutableListOf>() + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/folder_bubbles.xml b/app/src/main/res/drawable/folder_bubbles.xml new file mode 100644 index 0000000..590ac0d --- /dev/null +++ b/app/src/main/res/drawable/folder_bubbles.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/font/readex_pro_bold.ttf b/app/src/main/res/font/readex_pro_bold.ttf new file mode 100644 index 0000000..7f56cba Binary files /dev/null and b/app/src/main/res/font/readex_pro_bold.ttf differ diff --git a/app/src/main/res/font/readex_pro_extra_light.ttf b/app/src/main/res/font/readex_pro_extra_light.ttf new file mode 100644 index 0000000..f008ca6 Binary files /dev/null and b/app/src/main/res/font/readex_pro_extra_light.ttf differ diff --git a/app/src/main/res/font/readex_pro_light.ttf b/app/src/main/res/font/readex_pro_light.ttf new file mode 100644 index 0000000..bfbe921 Binary files /dev/null and b/app/src/main/res/font/readex_pro_light.ttf differ diff --git a/app/src/main/res/font/readex_pro_medium.ttf b/app/src/main/res/font/readex_pro_medium.ttf new file mode 100644 index 0000000..abd3cf9 Binary files /dev/null and b/app/src/main/res/font/readex_pro_medium.ttf differ diff --git a/app/src/main/res/font/readex_pro_regular.ttf b/app/src/main/res/font/readex_pro_regular.ttf new file mode 100644 index 0000000..93fc892 Binary files /dev/null and b/app/src/main/res/font/readex_pro_regular.ttf differ diff --git a/app/src/main/res/font/readex_pro_semi_bold.ttf b/app/src/main/res/font/readex_pro_semi_bold.ttf new file mode 100644 index 0000000..5ca7312 Binary files /dev/null and b/app/src/main/res/font/readex_pro_semi_bold.ttf differ diff --git a/build.gradle.kts b/build.gradle.kts index 4c8fd4a..8462ad7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - val kotlin_version = "1.8.10" + val kotlin_version = "1.8.22" repositories { google() @@ -20,6 +20,7 @@ allprojects { repositories { google() mavenCentral() + maven( url = "https://jitpack.io" ) } }