diff --git a/app/src/main/java/com/illusionware/npsbrowser/Routes.kt b/app/src/main/java/com/illusionware/npsbrowser/Routes.kt new file mode 100644 index 0000000..2d6b6f0 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/Routes.kt @@ -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") +} 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 871a9d6..8e4f800 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/activities/MainActivity.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/activities/MainActivity.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -82,19 +83,24 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import coil.compose.AsyncImage import coil.request.ImageRequest +import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.illusionware.npsbrowser.R +import com.illusionware.npsbrowser.Routes 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.screens.OnBoarding +import com.illusionware.npsbrowser.ui.screens.PackageDetails import com.illusionware.npsbrowser.ui.screens.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.util.isNavigationBarNeedsScrim import com.illusionware.npsbrowser.viewmodels.OnboardingViewModel +import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel import com.illusionware.npsbrowser.viewmodels.PackageListViewModel import com.illusionware.npsbrowser.viewmodels.SettingsViewModel import kotlinx.coroutines.runBlocking @@ -116,6 +122,7 @@ class MainActivity : ComponentActivity() { val onboardingViewModel: OnboardingViewModel = viewModel( factory = OnboardingViewModel.Factory ) + val packageDetailsViewModel: PackageDetailsViewModel = viewModel() val onboardingPrefs = onboardingViewModel.uiState.collectAsStateWithLifecycle().value val appTheme = runBlocking { settingsViewModel.getAppTheme() } @@ -135,29 +142,39 @@ class MainActivity : ComponentActivity() { ) { NavHost( 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( - navigateToSettings = { navController.navigate("Settings") }, + navigateToSettings = { navController.navigate(Routes.Settings.route) }, settingsPrefs = settingsPrefs, settingsViewModel = settingsViewModel, + navigateToDetailsScreen = { item -> + packageDetailsViewModel.setSelectedPackage(item) + navController.navigate(Routes.PackageDetails.route) + }, ) } - composable(route = "Settings") { + composable(Routes.Settings.route) { SettingsScreen( navigationGoBack = { navController.popBackStack() }, ) } - composable(route = "Onboarding") { + composable(Routes.Onboarding.route) { OnBoarding( navigateToHome = { navController.popBackStack() - navController.navigate("Home") + navController.navigate(Routes.Home.route) }, darkTheme = darkTheme, ) } + composable(Routes.PackageDetails.route) { + PackageDetails( + navigationGoBack = { navController.popBackStack() }, + packageDetailsViewModel = packageDetailsViewModel, + ) + } } } } @@ -168,7 +185,8 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomePage( - navigateToSettings: () -> Unit = {}, + navigateToSettings: () -> Unit, + navigateToDetailsScreen: (item: PackageItem) -> Unit, settingsPrefs: SettingsPreferences, settingsViewModel: SettingsViewModel, ) { @@ -176,9 +194,22 @@ fun HomePage( var searchQuery by rememberSaveable { mutableStateOf("")} val searchBar = remember { FocusRequester() } var layoutDialogOpen by remember { mutableStateOf(false) } + val context = LocalContext.current + val systemUiController = rememberSystemUiController() + val navbarScrimColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) 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( topBar = { @@ -272,7 +303,11 @@ fun HomePage( if (tsvs.isEmpty()) { NoTsvs(navigateToSettings = navigateToSettings) } 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 fun PackageList( type: ConsoleType = ConsoleType.PSVITA, + navigateToDetailsScreen: (item: PackageItem) -> Unit, viewModel: PackageListViewModel = viewModel( factory = PackageListViewModel.Factory ), @@ -429,7 +465,7 @@ fun PackageList( if (settingsPrefs.layout == ItemLayout.LIST.ordinal) { LazyColumn { items(packages) { - PackageListItem(it) + PackageListItem(it, navigateToDetailsScreen) } } } else { @@ -438,7 +474,7 @@ fun PackageList( contentPadding = PaddingValues(8.dp), ) { items(packages) { - PackageGridItem(it) + PackageGridItem(it, navigateToDetailsScreen) } } } @@ -448,11 +484,15 @@ fun PackageList( } @Composable -fun PackageListItem(item: PackageItem) { +fun PackageListItem( + item: PackageItem, + navigateToDetailsScreen: (item: PackageItem) -> Unit, +) { Box( Modifier .fillMaxWidth() - .clickable { /* TODO: */ }) { + .clickable { navigateToDetailsScreen(item) } + ) { Row( modifier = Modifier .padding(horizontal = 16.dp, vertical = 8.dp) @@ -500,8 +540,8 @@ fun PackageListItem(item: PackageItem) { ) { ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) { Text(text = item.region) - if (item.minFW.isNotEmpty()) { - Text(text = String.format("%.2f", item.minFW.toFloat())) + if (!item.minFW.isNullOrEmpty()) { + Text(text = item.minFW) } if (item.pkgSize.isNotEmpty()) { Text(text = item.pkgSize) @@ -512,14 +552,16 @@ fun PackageListItem(item: PackageItem) { } } Column { - if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") { + if (item.pkgUrl.isNullOrEmpty()) { 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())) { + if ( + item.consoleType != ConsoleType.PSX && + (item.zRif.isNullOrEmpty() && item.rap.isNullOrEmpty())) { Icon( painter = painterResource(id = R.drawable.no_key_icon), contentDescription = "No License Key", @@ -553,17 +595,20 @@ fun Tag(title: String, small: Boolean = false) { } @Composable -fun PackageGridItem(item: PackageItem) { +fun PackageGridItem( + item: PackageItem, + navigateToDetailsScreen: (item: PackageItem) -> Unit, +) { Box( Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .clickable { /* TODO: */ } + .clickable { navigateToDetailsScreen(item) }, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start, - modifier = Modifier.padding(8.dp) + modifier = Modifier.padding(8.dp), ) { Layout( content = { @@ -607,8 +652,8 @@ fun PackageGridItem(item: PackageItem) { ) { ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) { Text(text = item.region) - if (item.minFW.isNotEmpty()) { - Text(text = String.format("%.2f", item.minFW.toFloat())) + if (!item.minFW.isNullOrEmpty()) { + Text(text = item.minFW) } if (item.pkgSize.isNotEmpty()) { Text(text = item.pkgSize) @@ -616,14 +661,14 @@ fun PackageGridItem(item: PackageItem) { } } Row { - if (item.pkgUrl.isEmpty() || item.pkgUrl == "MISSING") { + if (item.pkgUrl.isNullOrEmpty()) { Icon( painter = painterResource(id = R.drawable.no_url_icon), contentDescription = "No PKG URL", tint = Color(0xFFCB5A5A) ) } - if (item.zRif.isEmpty() && item.rap.isEmpty()) { + if (item.zRif.isNullOrEmpty() && item.rap.isNullOrEmpty()) { Icon( painter = painterResource(id = R.drawable.no_key_icon), contentDescription = "No License Key", diff --git a/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt index 1910ad2..291bc59 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/model/PackageItem.kt @@ -4,14 +4,14 @@ data class PackageItem( val titleId: String, val region: String, val name: String, - val pkgUrl: String, - val contentId: String, - val modificationDate: 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 sha256: String?, + val zRif: String?, + val rap: String?, + val minFW: String?, val consoleType: ConsoleType, val dataType: PackageItemType, diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSFloatingActionButton.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSFloatingActionButton.kt new file mode 100644 index 0000000..ec1434c --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/components/NPSFloatingActionButton.kt @@ -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") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/PackageDetails.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/PackageDetails.kt new file mode 100644 index 0000000..2a47344 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/PackageDetails.kt @@ -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) + } +} diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt index 3765ae3..555373b 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/screens/Settings.kt @@ -13,7 +13,9 @@ 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.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 @@ -43,6 +45,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboardManager @@ -98,12 +101,18 @@ fun SettingsScreen( Text(text = stringResource(id = R.string.title_activity_settings), style = Typography.titleLarge) } }, + contentWindowInsets = WindowInsets(bottom = 0.dp), ) { padding -> Column(modifier = Modifier .padding(padding) .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) TsvFiles(viewModel, prefs) Updates(viewModel, prefs) @@ -366,6 +375,7 @@ fun Downloads(viewModel: SettingsViewModel, prefs: SettingsPreferences) { title = "Unpack Directory", value = Uri.decode(prefs.unpackDir).ifEmpty { "None" }, icon = Icons.Outlined.Unarchive, + enabled = !prefs.unpackInDownload, onClick = { unpackDirPicker.launch(Uri.EMPTY) } @@ -514,12 +524,14 @@ fun DialogSetting( value: String, icon: ImageVector?, onClick: () -> Unit, - singleLine : Boolean = false + singleLine : Boolean = false, + enabled: Boolean = true ) { Box( Modifier .fillMaxWidth() - .clickable { onClick() } + .alpha(if (enabled) 1f else 0.5f) + .then(if (enabled) Modifier.clickable { onClick() } else Modifier) ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt b/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt index bff364b..8ee2337 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/ui/theme/Theme.kt @@ -98,12 +98,12 @@ fun NPSBrowserTheme( darkTheme -> DarkColorScheme else -> LightColorScheme } - val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window window.statusBarColor = Color.Transparent.toArgb() + window.navigationBarColor = Color.Transparent.toArgb() // allows pages to extend to the status bar WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/DisplayExtensions.kt b/app/src/main/java/com/illusionware/npsbrowser/util/DisplayExtensions.kt new file mode 100644 index 0000000..a4131e0 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/DisplayExtensions.kt @@ -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) +} diff --git a/app/src/main/java/com/illusionware/npsbrowser/util/InternalResourceHelper.kt b/app/src/main/java/com/illusionware/npsbrowser/util/InternalResourceHelper.kt new file mode 100644 index 0000000..96add7d --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/util/InternalResourceHelper.kt @@ -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") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageDetailsViewModel.kt b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageDetailsViewModel.kt new file mode 100644 index 0000000..1746b68 --- /dev/null +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageDetailsViewModel.kt @@ -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 = _uiState.asStateFlow() + + fun setSelectedPackage(item: PackageItem) { + _uiState.value = SelectedPackageUiState(item) + } +} \ 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 c68db0c..1c1fbb5 100644 --- a/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt +++ b/app/src/main/java/com/illusionware/npsbrowser/viewmodels/PackageListData.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import okhttp3.internal.toLongOrDefault import java.io.InputStream data class PackageListUiState( @@ -91,24 +92,64 @@ class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesReposit val tsv = tsvReader.readAllWithHeader(file!!) tsv.forEach { row -> - var size = "" - val tempSize = row["File Size"] - if (!tempSize.isNullOrEmpty()) { - size = bytesIntoHumanReadable(tempSize.toLong()) + if (row["Name"].isNullOrEmpty()) { + return@forEach } + 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( PackageItem( row["Title ID"] ?: "", row["Region"] ?: "", - row["Name"] ?: "", - row["PKG direct link"] ?: "", - row["Content ID"] ?: "", - row["Last Modification Date"] ?: "", + row["Name"]?.trim() ?: "", + pkgUrl, + contentId, + modificationDate, size, - row["SHA256"] ?: "", - row["zRIF"] ?: "", - row["RAP"] ?: "", - row["Required FW"] ?: "", + sha256, + zRif, + rap, + minFW, console, type, ) diff --git a/app/src/main/res/drawable/gear.xml b/app/src/main/res/drawable/gear.xml new file mode 100644 index 0000000..99f47d8 --- /dev/null +++ b/app/src/main/res/drawable/gear.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/key_copy_fill.xml b/app/src/main/res/drawable/key_copy_fill.xml new file mode 100644 index 0000000..2f60750 --- /dev/null +++ b/app/src/main/res/drawable/key_copy_fill.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/package_ic.xml b/app/src/main/res/drawable/package_ic.xml new file mode 100644 index 0000000..d123eb5 --- /dev/null +++ b/app/src/main/res/drawable/package_ic.xml @@ -0,0 +1,4 @@ + + +