package details screen and other various fixes and improvements

This commit is contained in:
2023-07-14 08:34:45 +03:00
parent b233d44c5c
commit 2178a0c9a4
14 changed files with 778 additions and 47 deletions
@@ -0,0 +1,8 @@
package com.illusionware.npsbrowser
sealed class Routes(val route: String) {
object Home : Routes("home")
object Onboarding : Routes("onboarding")
object Settings : Routes("settings")
object PackageDetails : Routes("package_details")
}
@@ -46,6 +46,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -82,19 +83,24 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.illusionware.npsbrowser.R import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.Routes
import com.illusionware.npsbrowser.data.ItemLayout import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.model.ConsoleType import com.illusionware.npsbrowser.model.ConsoleType
import com.illusionware.npsbrowser.model.PackageItem import com.illusionware.npsbrowser.model.PackageItem
import com.illusionware.npsbrowser.ui.components.* import com.illusionware.npsbrowser.ui.components.*
import com.illusionware.npsbrowser.ui.screens.OnBoarding import com.illusionware.npsbrowser.ui.screens.OnBoarding
import com.illusionware.npsbrowser.ui.screens.PackageDetails
import com.illusionware.npsbrowser.ui.screens.SettingsScreen import com.illusionware.npsbrowser.ui.screens.SettingsScreen
import com.illusionware.npsbrowser.ui.theme.ColorAccent import com.illusionware.npsbrowser.ui.theme.ColorAccent
import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight
import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme
import com.illusionware.npsbrowser.ui.theme.Typography import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.util.isNavigationBarNeedsScrim
import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
import com.illusionware.npsbrowser.viewmodels.PackageListViewModel import com.illusionware.npsbrowser.viewmodels.PackageListViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -116,6 +122,7 @@ class MainActivity : ComponentActivity() {
val onboardingViewModel: OnboardingViewModel = viewModel( val onboardingViewModel: OnboardingViewModel = viewModel(
factory = OnboardingViewModel.Factory factory = OnboardingViewModel.Factory
) )
val packageDetailsViewModel: PackageDetailsViewModel = viewModel()
val onboardingPrefs = onboardingViewModel.uiState.collectAsStateWithLifecycle().value val onboardingPrefs = onboardingViewModel.uiState.collectAsStateWithLifecycle().value
val appTheme = runBlocking { settingsViewModel.getAppTheme() } val appTheme = runBlocking { settingsViewModel.getAppTheme() }
@@ -135,29 +142,39 @@ class MainActivity : ComponentActivity() {
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (onboardingPrefs.seenOnboarding) "Home" else "Onboarding", startDestination = if (onboardingPrefs.seenOnboarding) Routes.Home.route else Routes.Onboarding.route,
) { ) {
composable(route = "Home") { composable(Routes.Home.route) {
HomePage( HomePage(
navigateToSettings = { navController.navigate("Settings") }, navigateToSettings = { navController.navigate(Routes.Settings.route) },
settingsPrefs = settingsPrefs, settingsPrefs = settingsPrefs,
settingsViewModel = settingsViewModel, settingsViewModel = settingsViewModel,
navigateToDetailsScreen = { item ->
packageDetailsViewModel.setSelectedPackage(item)
navController.navigate(Routes.PackageDetails.route)
},
) )
} }
composable(route = "Settings") { composable(Routes.Settings.route) {
SettingsScreen( SettingsScreen(
navigationGoBack = { navController.popBackStack() }, navigationGoBack = { navController.popBackStack() },
) )
} }
composable(route = "Onboarding") { composable(Routes.Onboarding.route) {
OnBoarding( OnBoarding(
navigateToHome = { navigateToHome = {
navController.popBackStack() navController.popBackStack()
navController.navigate("Home") navController.navigate(Routes.Home.route)
}, },
darkTheme = darkTheme, darkTheme = darkTheme,
) )
} }
composable(Routes.PackageDetails.route) {
PackageDetails(
navigationGoBack = { navController.popBackStack() },
packageDetailsViewModel = packageDetailsViewModel,
)
}
} }
} }
} }
@@ -168,7 +185,8 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomePage( fun HomePage(
navigateToSettings: () -> Unit = {}, navigateToSettings: () -> Unit,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
settingsPrefs: SettingsPreferences, settingsPrefs: SettingsPreferences,
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
) { ) {
@@ -176,9 +194,22 @@ fun HomePage(
var searchQuery by rememberSaveable { mutableStateOf("")} var searchQuery by rememberSaveable { mutableStateOf("")}
val searchBar = remember { FocusRequester() } val searchBar = remember { FocusRequester() }
var layoutDialogOpen by remember { mutableStateOf(false) } var layoutDialogOpen by remember { mutableStateOf(false) }
val context = LocalContext.current
val systemUiController = rememberSystemUiController()
val navbarScrimColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
val tsvs = settingsViewModel.availableTsvs() val tsvs = settingsViewModel.availableTsvs()
var selectedBarItem by remember { mutableStateOf(ConsoleType.PSVITA) } var selectedBarItem by rememberSaveable { mutableStateOf(ConsoleType.PSVITA) }
LaunchedEffect(systemUiController) {
systemUiController.setNavigationBarColor(
color = if (context.isNavigationBarNeedsScrim()) {
navbarScrimColor.copy(alpha = 0.7f)
} else {
Color.Transparent
}
)
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -272,7 +303,11 @@ fun HomePage(
if (tsvs.isEmpty()) { if (tsvs.isEmpty()) {
NoTsvs(navigateToSettings = navigateToSettings) NoTsvs(navigateToSettings = navigateToSettings)
} else { } else {
PackageList(selectedBarItem, settingsPrefs = settingsPrefs) PackageList(
navigateToDetailsScreen = navigateToDetailsScreen,
type = selectedBarItem,
settingsPrefs = settingsPrefs
)
} }
} }
@@ -375,6 +410,7 @@ fun NoTsvs(padding: PaddingValues = PaddingValues(0.dp), navigateToSettings: ()
@Composable @Composable
fun PackageList( fun PackageList(
type: ConsoleType = ConsoleType.PSVITA, type: ConsoleType = ConsoleType.PSVITA,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
viewModel: PackageListViewModel = viewModel( viewModel: PackageListViewModel = viewModel(
factory = PackageListViewModel.Factory factory = PackageListViewModel.Factory
), ),
@@ -429,7 +465,7 @@ fun PackageList(
if (settingsPrefs.layout == ItemLayout.LIST.ordinal) { if (settingsPrefs.layout == ItemLayout.LIST.ordinal) {
LazyColumn { LazyColumn {
items(packages) { items(packages) {
PackageListItem(it) PackageListItem(it, navigateToDetailsScreen)
} }
} }
} else { } else {
@@ -438,7 +474,7 @@ fun PackageList(
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
) { ) {
items(packages) { items(packages) {
PackageGridItem(it) PackageGridItem(it, navigateToDetailsScreen)
} }
} }
} }
@@ -448,11 +484,15 @@ fun PackageList(
} }
@Composable @Composable
fun PackageListItem(item: PackageItem) { fun PackageListItem(
item: PackageItem,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { /* TODO: */ }) { .clickable { navigateToDetailsScreen(item) }
) {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
@@ -500,8 +540,8 @@ fun PackageListItem(item: PackageItem) {
) { ) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) { ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = item.region) Text(text = item.region)
if (item.minFW.isNotEmpty()) { if (!item.minFW.isNullOrEmpty()) {
Text(text = String.format("%.2f", item.minFW.toFloat())) Text(text = item.minFW)
} }
if (item.pkgSize.isNotEmpty()) { if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize) Text(text = item.pkgSize)
@@ -512,14 +552,16 @@ fun PackageListItem(item: PackageItem) {
} }
} }
Column { Column {
if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") { if (item.pkgUrl.isNullOrEmpty()) {
Icon( Icon(
painter = painterResource(id = R.drawable.no_url_icon), painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL", contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A) tint = Color(0xFFCB5A5A)
) )
} }
if (item.consoleType != ConsoleType.PSX && (item.zRif.isEmpty() && item.rap.isEmpty())) { if (
item.consoleType != ConsoleType.PSX &&
(item.zRif.isNullOrEmpty() && item.rap.isNullOrEmpty())) {
Icon( Icon(
painter = painterResource(id = R.drawable.no_key_icon), painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No License Key", contentDescription = "No License Key",
@@ -553,17 +595,20 @@ fun Tag(title: String, small: Boolean = false) {
} }
@Composable @Composable
fun PackageGridItem(item: PackageItem) { fun PackageGridItem(
item: PackageItem,
navigateToDetailsScreen: (item: PackageItem) -> Unit,
) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { /* TODO: */ } .clickable { navigateToDetailsScreen(item) },
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp),
) { ) {
Layout( Layout(
content = { content = {
@@ -607,8 +652,8 @@ fun PackageGridItem(item: PackageItem) {
) { ) {
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) { ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
Text(text = item.region) Text(text = item.region)
if (item.minFW.isNotEmpty()) { if (!item.minFW.isNullOrEmpty()) {
Text(text = String.format("%.2f", item.minFW.toFloat())) Text(text = item.minFW)
} }
if (item.pkgSize.isNotEmpty()) { if (item.pkgSize.isNotEmpty()) {
Text(text = item.pkgSize) Text(text = item.pkgSize)
@@ -616,14 +661,14 @@ fun PackageGridItem(item: PackageItem) {
} }
} }
Row { Row {
if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") { if (item.pkgUrl.isNullOrEmpty()) {
Icon( Icon(
painter = painterResource(id = R.drawable.no_url_icon), painter = painterResource(id = R.drawable.no_url_icon),
contentDescription = "No PKG URL", contentDescription = "No PKG URL",
tint = Color(0xFFCB5A5A) tint = Color(0xFFCB5A5A)
) )
} }
if (item.zRif.isEmpty() && item.rap.isEmpty()) { if (item.zRif.isNullOrEmpty() && item.rap.isNullOrEmpty()) {
Icon( Icon(
painter = painterResource(id = R.drawable.no_key_icon), painter = painterResource(id = R.drawable.no_key_icon),
contentDescription = "No License Key", contentDescription = "No License Key",
@@ -4,14 +4,14 @@ data class PackageItem(
val titleId: String, val titleId: String,
val region: String, val region: String,
val name: String, val name: String,
val pkgUrl: String, val pkgUrl: String?,
val contentId: String, val contentId: String?,
val modificationDate: String, val modificationDate: String?,
val pkgSize: String, val pkgSize: String,
val sha256: String, val sha256: String?,
val zRif: String, val zRif: String?,
val rap: String, val rap: String?,
val minFW: String, val minFW: String?,
val consoleType: ConsoleType, val consoleType: ConsoleType,
val dataType: PackageItemType, val dataType: PackageItemType,
@@ -0,0 +1,95 @@
package com.illusionware.npsbrowser.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Eco
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NPSFloatingActionButton(
modifier: Modifier = Modifier,
enabled: Boolean = true,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
shape: Shape = FloatingActionButtonDefaults.shape,
containerColor: Color = FloatingActionButtonDefaults.containerColor,
contentColor: Color = MaterialTheme.colorScheme.primary,
elevation: Dp = 6.dp,
content: @Composable () -> Unit,
) {
val haptic = LocalHapticFeedback.current
Surface(
modifier = modifier
.alpha(if (enabled) 1f else 0.5f)
.then(if (enabled) Modifier.shadow(6.dp, shape) else Modifier.clip(shape))
.then(if (enabled) Modifier.combinedClickable(
onClick = { onClick() },
onLongClick = {
if (onLongClick != null) {
onLongClick()
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
) else Modifier)
.semantics(
mergeDescendants = true,
properties = {
if (!enabled) disabled()
onClick { onClick(); true }
if (onLongClick != null) {
onLongClick { onLongClick(); true }
}
role = Role.Button
},
),
color = containerColor,
tonalElevation = 3.dp,
shadowElevation = elevation,
contentColor = contentColor,
) {
Box(
modifier = Modifier.padding(16.dp)
) {
Row {
content()
}
}
}
}
@Composable
@Preview
fun NPSFABPreview() {
NPSFloatingActionButton(
onClick = {},
) {
Icon(imageVector = Icons.Outlined.Eco, contentDescription = "Eco")
}
}
@@ -0,0 +1,456 @@
package com.illusionware.npsbrowser.ui.screens
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SheetState
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.illusionware.npsbrowser.R
import com.illusionware.npsbrowser.activities.PlaceholderColor
import com.illusionware.npsbrowser.data.ItemLayout
import com.illusionware.npsbrowser.data.SettingsPreferences
import com.illusionware.npsbrowser.data.Theme
import com.illusionware.npsbrowser.ui.components.NPSAlertDialog
import com.illusionware.npsbrowser.ui.components.NPSFloatingActionButton
import com.illusionware.npsbrowser.ui.components.NPSIconButton
import com.illusionware.npsbrowser.ui.theme.ColorSecondaryLight
import com.illusionware.npsbrowser.ui.theme.Typography
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PackageDetails(
navigationGoBack: () -> Unit = {},
settingsViewModel: SettingsViewModel = viewModel(
factory = SettingsViewModel.Factory
),
packageDetailsViewModel: PackageDetailsViewModel,
) {
val clipboard = LocalClipboardManager.current
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val prefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
var showBottomSheet by remember { mutableStateOf(false) }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState)},
topBar = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.statusBarsPadding()
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
NPSIconButton(tooltip = "Go Back", onClick = navigationGoBack) {
Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = "Go Back" )
}
}
},
floatingActionButton = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NPSFloatingActionButton(
onClick = {
if (!item.zRif.isNullOrEmpty()) {
clipboard.setText(AnnotatedString(item.zRif))
} else if (!item.rap.isNullOrEmpty()) {
clipboard.setText(AnnotatedString(item.rap))
}
scope.launch {
snackbarHostState.showSnackbar("License key copied to clipboard")
}
},
enabled = !item.zRif.isNullOrEmpty() || !item.rap.isNullOrEmpty()
) {
Icon(
painter = painterResource(id = R.drawable.key_copy_fill),
contentDescription = "Copy License Key"
)
}
NPSFloatingActionButton(
onClick = { /* TODO: */ },
onLongClick = { showBottomSheet = true },
enabled = !item.pkgUrl.isNullOrEmpty()
) {
Icon(imageVector = Icons.Filled.Download, contentDescription = "Download")
}
}
},
) { padding ->
Column(Modifier.padding(padding)) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("TODO")
.crossfade(true)
.error(R.drawable.default_game_icon)
.build(),
placeholder = ColorPainter(PlaceholderColor),
contentDescription = "Package Icon",
modifier = Modifier
.aspectRatio(1f / 1f)
.fillMaxWidth(),
contentScale = ContentScale.Crop,
)
Column(Modifier.padding(16.dp)) {
Text(
text = "[${item.titleId}] ${item.name}",
style = Typography.headlineSmall.copy(fontSize = 20.sp),
fontWeight = FontWeight.Light,
)
if (!item.contentId.isNullOrEmpty()) {
Spacer(modifier = Modifier.padding(4.dp))
Text(
text = item.contentId,
style = Typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.padding(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
if (item.region.isNotEmpty()) {
Info(
label = item.region,
iconDescription = "Region",
icon = Icons.Filled.Public
)
}
if (item.pkgSize.isNotEmpty()) {
Info(
label = item.pkgSize,
iconDescription = "Package",
icon = painterResource(id = R.drawable.package_ic),
)
}
if (!item.minFW.isNullOrEmpty()) {
Info(
label = item.minFW,
iconDescription = "Minimum Firmware",
icon = painterResource(id = R.drawable.gear),
)
}
}
}
}
}
if (showBottomSheet) {
OneTimeConfigBottomSheet(
onDismiss = {
scope.launch {
sheetState.hide()
showBottomSheet = false
}
},
sheetState = sheetState,
prefs = prefs,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OneTimeConfigBottomSheet(
sheetState: SheetState,
onDismiss: () -> Unit,
prefs: SettingsPreferences
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
windowInsets = WindowInsets(top = 0.dp),
) {
OneTimeConfigSheetContent(prefs)
}
}
@Composable
fun Info(label: String, iconDescription: String, icon: Painter) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Text(
text = label,
style = Typography.bodyMedium,
)
}
}
@Composable
fun Info(label: String, iconDescription: String, icon: ImageVector) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = iconDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Text(
text = label,
style = Typography.bodyMedium,
)
}
}
@Composable
fun Option(
title: String,
value: String,
onClick: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.clickable { onClick() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 16.dp, horizontal = 24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
Text(text = value, color = ColorSecondaryLight, fontSize = 14.sp)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OneTimeConfigSheetContent(prefs: SettingsPreferences) {
var autoDecrypt by remember { mutableStateOf(prefs.pkg2zipAutoDecrypt) }
var unpackInPlace by remember { mutableStateOf(prefs.unpackInDownload) }
var deleteAfter by remember { mutableStateOf(prefs.deleteAfterUnpack) }
var downloadDir by remember { mutableStateOf(Uri.decode(prefs.downloadDir)) }
var decParamsDialog by remember { mutableStateOf(false) }
var unpackDir by remember { mutableStateOf(Uri.decode(prefs.unpackDir)) }
var pkg2zipParams by remember { mutableStateOf(prefs.pkg2zipParams) }
var displayPkg2zipParams by remember { mutableStateOf(prefs.pkg2zipParams) }
val downloadDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
downloadDir = dirUri.toString()
}
}
val unpackDirPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { dirUri ->
if (dirUri != null) {
unpackDir = dirUri.toString()
}
}
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.navigationBarsPadding()
) {
Text(
text = "One-time Configuration",
textAlign = TextAlign.Center,
style = Typography.titleLarge
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
) {
FilterChip(
label = { Text(text = "Decrypt automatically") },
selected = autoDecrypt,
onClick = { autoDecrypt = !autoDecrypt }
)
FilterChip(
label = { Text(text = "Unpack in place") },
selected = unpackInPlace,
onClick = { unpackInPlace = !unpackInPlace },
)
FilterChip(
label = { Text(text = "Delete after") },
selected = deleteAfter,
onClick = { deleteAfter = !deleteAfter },
)
}
Column(
modifier = Modifier.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.End,
) {
Column {
Option(
title = "Download Directory",
value = Uri.decode(downloadDir).ifEmpty { "None" },
onClick = {
downloadDirPicker.launch(Uri.EMPTY)
}
)
AnimatedVisibility(
visible = !unpackInPlace
) {
Option(
title = "Unpack Directory",
value = Uri.decode(unpackDir).ifEmpty { "None" },
onClick = {
unpackDirPicker.launch(Uri.EMPTY)
}
)
}
AnimatedVisibility(
visible = autoDecrypt
) {
Option(
title = "Decryption Parameters",
value = pkg2zipParams,
onClick = { decParamsDialog = true }
)
}
}
Button(
onClick = { /*TODO: do the download*/ },
modifier = Modifier.padding(horizontal = 24.dp),
) {
Icon(
imageVector = Icons.Filled.DownloadDone,
contentDescription = "Download",
Modifier.size(20.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text("Download")
}
}
}
if (decParamsDialog) {
NPSAlertDialog(
onDismiss = {
decParamsDialog = false
displayPkg2zipParams = pkg2zipParams
},
title = "Decryption Parameters",
buttons = {
TextButton(onClick = {
pkg2zipParams = displayPkg2zipParams
decParamsDialog = false
}) {
Text(text = "OK")
}
}
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = displayPkg2zipParams,
label = { Text(text = "Decryption Parameters") },
onValueChange = {
displayPkg2zipParams = it
},
)
}
}
}
@Composable
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
fun BottomSheetPreview() {
val prefs = SettingsPreferences(
Theme.SYSTEM.ordinal,
ItemLayout.LIST.ordinal,
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
false,
false,
"",
true,
)
Column {
OneTimeConfigSheetContent(prefs = prefs)
}
}
@@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
@@ -43,6 +45,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
@@ -98,12 +101,18 @@ fun SettingsScreen(
Text(text = stringResource(id = R.string.title_activity_settings), style = Typography.titleLarge) Text(text = stringResource(id = R.string.title_activity_settings), style = Typography.titleLarge)
} }
}, },
contentWindowInsets = WindowInsets(bottom = 0.dp),
) { padding -> ) { padding ->
Column(modifier = Modifier Column(modifier = Modifier
.padding(padding) .padding(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.padding(vertical = 16.dp)) { Column(
verticalArrangement = Arrangement.spacedBy(20.dp),
modifier = Modifier
.padding(vertical = 16.dp)
.navigationBarsPadding()
) {
Appearance(viewModel, prefs) Appearance(viewModel, prefs)
TsvFiles(viewModel, prefs) TsvFiles(viewModel, prefs)
Updates(viewModel, prefs) Updates(viewModel, prefs)
@@ -366,6 +375,7 @@ fun Downloads(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
title = "Unpack Directory", title = "Unpack Directory",
value = Uri.decode(prefs.unpackDir).ifEmpty { "None" }, value = Uri.decode(prefs.unpackDir).ifEmpty { "None" },
icon = Icons.Outlined.Unarchive, icon = Icons.Outlined.Unarchive,
enabled = !prefs.unpackInDownload,
onClick = { onClick = {
unpackDirPicker.launch(Uri.EMPTY) unpackDirPicker.launch(Uri.EMPTY)
} }
@@ -514,12 +524,14 @@ fun DialogSetting(
value: String, value: String,
icon: ImageVector?, icon: ImageVector?,
onClick: () -> Unit, onClick: () -> Unit,
singleLine : Boolean = false singleLine : Boolean = false,
enabled: Boolean = true
) { ) {
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .alpha(if (enabled) 1f else 0.5f)
.then(if (enabled) Modifier.clickable { onClick() } else Modifier)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -98,12 +98,12 @@ fun NPSBrowserTheme(
darkTheme -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
window.statusBarColor = Color.Transparent.toArgb() window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb()
// allows pages to extend to the status bar // allows pages to extend to the status bar
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
@@ -0,0 +1,10 @@
package com.illusionware.npsbrowser.util
import android.content.Context
import android.os.Build
// Borrowed from https://github.com/tachiyomiorg/tachiyomi/tree/master
fun Context.isNavigationBarNeedsScrim(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ||
InternalResourceHelper.getBoolean(this, "config_navBarNeedsScrim", true)
}
@@ -0,0 +1,28 @@
package com.illusionware.npsbrowser.util
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
// Borrowed from https://github.com/tachiyomiorg/tachiyomi/tree/master
object InternalResourceHelper {
fun getBoolean(context: Context, resName: String, defaultValue: Boolean): Boolean {
val id = getResourceId(resName, "bool")
return if (id != 0) {
context.createPackageContext("android", 0).resources.getBoolean(id)
} else {
defaultValue
}
}
/**
* Get resource id from system resources
* @param resName resource name to get
* @param type resource type of [resName] to get
* @return 0 if not available
*/
@SuppressLint("DiscouragedApi")
private fun getResourceId(resName: String, type: String): Int {
return Resources.getSystem().getIdentifier(resName, type, "android")
}
}
@@ -0,0 +1,20 @@
package com.illusionware.npsbrowser.viewmodels
import androidx.lifecycle.ViewModel
import com.illusionware.npsbrowser.model.PackageItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class SelectedPackageUiState(
val item: PackageItem?,
)
class PackageDetailsViewModel: ViewModel() {
private val _uiState = MutableStateFlow(SelectedPackageUiState(null))
val uiState: StateFlow<SelectedPackageUiState> = _uiState.asStateFlow()
fun setSelectedPackage(item: PackageItem) {
_uiState.value = SelectedPackageUiState(item)
}
}
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.internal.toLongOrDefault
import java.io.InputStream import java.io.InputStream
data class PackageListUiState( data class PackageListUiState(
@@ -91,24 +92,64 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit
val tsv = tsvReader.readAllWithHeader(file!!) val tsv = tsvReader.readAllWithHeader(file!!)
tsv.forEach { row -> tsv.forEach { row ->
var size = "" if (row["Name"].isNullOrEmpty()) {
val tempSize = row["File Size"] return@forEach
if (!tempSize.isNullOrEmpty()) {
size = bytesIntoHumanReadable(tempSize.toLong())
} }
var size = ""
var minFW: String? = null
var pkgUrl: String? = null
var zRif: String? = null
var rap: String? = null
var contentId: String? = null
var sha256: String? = null
var modificationDate: String? = null
val tempSize = row["File Size"]
val tempFW = row["Required FW"]
val tempUrl = row["PKG direct link"]
val tempZRif = row["zRIF"]
val tempRap = row["RAP"]
val tempContentId = row["Content ID"]
val tempsha256 = row["SHA256"]
val tempDate = row["Last Modification Date"]
if (!tempSize.isNullOrEmpty()) {
size = bytesIntoHumanReadable(tempSize.toLongOrDefault(0))
}
if (!tempFW.isNullOrEmpty()) {
minFW = String.format("%.2f", tempFW.toFloat())
}
if (!tempUrl.isNullOrEmpty() && tempUrl != "MISSING") {
pkgUrl = tempUrl
}
if (!tempZRif.isNullOrEmpty() && tempZRif != "MISSING") {
zRif = tempZRif
}
if (!tempRap.isNullOrEmpty() && tempRap != "MISSING") {
rap = tempRap
}
if (!tempContentId.isNullOrEmpty()) {
contentId = tempContentId
}
if (!tempsha256.isNullOrEmpty()) {
sha256 = tempsha256
}
if (!tempDate.isNullOrEmpty()) {
modificationDate = tempDate
}
list.add( list.add(
PackageItem( PackageItem(
row["Title ID"] ?: "", row["Title ID"] ?: "",
row["Region"] ?: "", row["Region"] ?: "",
row["Name"] ?: "", row["Name"]?.trim() ?: "",
row["PKG direct link"] ?: "", pkgUrl,
row["Content ID"] ?: "", contentId,
row["Last Modification Date"] ?: "", modificationDate,
size, size,
row["SHA256"] ?: "", sha256,
row["zRIF"] ?: "", zRif,
row["RAP"] ?: "", rap,
row["Required FW"] ?: "", minFW,
console, console,
type, type,
) )
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="256"
android:viewportWidth="256" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000"
android:pathData="m139.8,13.09a72.78,72.78 0,0 0,-69.17 95.25l-52.33,52.31a7.27,7.27 0,0 0,-2.13 5.16v36.36a7.27,7.27 0,0 0,7.27 7.27h12.95v-24.08a7.27,7.27 0,0 1,2.14 -5.14l52.31,-52.33A72.78,72.78 0,0 1,160.02 32.66,72.72 72.72,0 0,1 194.86,43 72.72,72.72 0,0 0,139.8 13.09ZM178.52,73.52a14.54,14.54 0,0 0,-9.95 3.66,14.54 14.54,0 0,1 -0.27,0.64 14.54,14.54 0,0 0,5.89 22,15.7 15.7,0 0,1 -0.98,-4.92 15.7,15.7 0,0 1,15.7 -15.69,15.7 15.7,0 0,1 0.16,0.02 14.54,14.54 0,0 0,-10.55 -5.7z" android:strokeWidth="0.908993"/>
<path android:fillColor="#000000"
android:pathData="M169.69,40.2A72.78,72.78 0,0 0,100.52 135.45l-52.33,52.32a7.27,7.27 0,0 0,-2.13 5.14v36.36a7.27,7.27 0,0 0,7.27 7.27h36.36a7.27,7.27 0,0 0,7.27 -7.27v-14.54h14.54a7.27,7.27 0,0 0,7.27 -7.27v-14.54h14.54a7.27,7.27 0,0 0,5.14 -2.13l8.69,-8.7A72.72,72.72 0,1 0,169.69 40.2ZM187.87,109.29a14.54,14.54 0,1 1,14.54 -14.54,14.54 14.54,0 0,1 -14.54,14.54z" android:strokeWidth="0.908993"/>
</vector>
+4
View File
@@ -0,0 +1,4 @@
<vector android:height="24dp" android:viewportHeight="256"
android:viewportWidth="256" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#000000" android:pathData="M223.68,66.15 L135.68,18a15.88,15.88 0,0 0,-15.36 0l-88,48.17a16,16 0,0 0,-8.32 14v95.64a16,16 0,0 0,8.32 14l88,48.17a15.88,15.88 0,0 0,15.36 0l88,-48.17a16,16 0,0 0,8.32 -14L232,80.18A16,16 0,0 0,223.68 66.15ZM128,32l80.35,44L178.57,92.29l-80.35,-44ZM128,120L47.65,76 81.56,57.43l80.35,44ZM216,175.85h0l-80,43.79L136,133.83l32,-17.51L168,152a8,8 0,0 0,16 0L184,107.56l32,-17.51v85.76Z"/>
</vector>