mirror of
https://github.com/IllusionMan1212/NPS-Browser.git
synced 2026-10-24 18:12:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d775395efd
|
||
|
|
9e582d3788
|
||
|
|
4b84275f3a
|
||
|
|
bcd2591ba7
|
||
|
|
2178a0c9a4
|
||
|
|
b233d44c5c
|
||
|
|
f4d9b90937
|
||
|
|
6cf6cd6f08
|
||
|
|
b6219355bd
|
||
|
|
bd50c73354
|
||
|
|
4d6fb0419e
|
||
|
|
5c7c1d9036
|
||
|
|
a99be170c0
|
||
|
|
759f584a67
|
||
|
|
c6b7df23ed
|
@@ -1,4 +1,3 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
@@ -7,7 +6,6 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
Download Manager :-
|
||||
- Working on :-
|
||||
- Trying to get OkHttp requests to cancel
|
||||
|
||||
- Key :-
|
||||
- Show progress on the UI
|
||||
- Sync download notification with UI
|
||||
|
||||
- Other fixes for the notification
|
||||
|
||||
- Nice To Haves :-
|
||||
- Show error message in the UI instead of a generic "Failed"
|
||||
- Simultaneous downloading
|
||||
- Reordering of queue
|
||||
|
||||
- Testing :-
|
||||
- Pausing (UI "works" but download doesn't pause)
|
||||
|
||||
- Bugs :-
|
||||
- Some packages REFUSE to download for some reason and fail with an IO FILE EXISTS (or doesn't?) error
|
||||
- Notification is persistent even after completion and cancellation (I think, needs testing)
|
||||
@@ -1,87 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-parcelize'
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('keystore.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.illusionware.npsbrowser'
|
||||
compileSdkVersion 33
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.illusionware.npsbrowser"
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 33
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.release
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
buildToolsVersion '33.0.1'
|
||||
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.6.0'
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
|
||||
implementation 'com.github.doyaaaaaken:kotlin-csv-jvm:1.8.0'
|
||||
implementation 'com.github.wrdlbrnft:sorted-list-adapter:0.3.0.27'
|
||||
implementation "androidx.cardview:cardview:1.0.0"
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.14.2'
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.14.2'
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
id("com.google.devtools.ksp") version "2.2.0-2.0.2"
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.illusionware.npsbrowser"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.illusionware.npsbrowser"
|
||||
// TODO: Network Security Config needs 24+ API level, don't know if I want to raise the min
|
||||
// just for that
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.2"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
|
||||
aidl = false
|
||||
renderScript = false
|
||||
shaders = false
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
named("release") {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles("proguard-android-optimize.txt", "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildToolsVersion = "36.0.0"
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
// Jetpack Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
// Room database
|
||||
implementation(libs.androidx.room.runtime)
|
||||
// To use Kotlin Symbol Processing (KSP)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// optional - Kotlin Extensions and Coroutines support for Room
|
||||
implementation(libs.androidx.room.ktx)
|
||||
|
||||
|
||||
// Material 3
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.material.icons.extended)
|
||||
// Android Studio Preview support
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
// Preferences DataStore (alternative to SharedPreferences)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(libs.coil)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.rxjava)
|
||||
implementation(libs.okio)
|
||||
implementation(libs.flowreactivenetwork)
|
||||
|
||||
implementation(libs.kotlin.csv)
|
||||
implementation(libs.material)
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
# proguardFiles setting in build.gradle.kts.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "1560ee26782336728f9f46d05e39072d",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "download",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `titleId` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER NOT NULL, `bytesDownloaded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`url`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "titleId",
|
||||
"columnName": "titleId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sha256",
|
||||
"columnName": "sha256",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bytesDownloaded",
|
||||
"columnName": "bytesDownloaded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1560ee26782336728f9f46d05e39072d')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "006b9f2d98a94abb01489e5a0c1837aa",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "download",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `titleId` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER NOT NULL, `bytesDownloaded` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `statusValue` INTEGER NOT NULL DEFAULT 0, `savedFileName` TEXT, PRIMARY KEY(`url`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "titleId",
|
||||
"columnName": "titleId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sha256",
|
||||
"columnName": "sha256",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "size",
|
||||
"columnName": "size",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bytesDownloaded",
|
||||
"columnName": "bytesDownloaded",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "statusValue",
|
||||
"columnName": "statusValue",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "savedFileName",
|
||||
"columnName": "savedFileName",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '006b9f2d98a94abb01489e5a0c1837aa')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,46 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:name=".NPSApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/NPSTheme">
|
||||
<activity android:name=".activities.SplashActivity" android:theme="@style/NPSTheme.Splash"
|
||||
android:exported="true">
|
||||
android:theme="@style/NPSTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation"
|
||||
android:exported="true"
|
||||
android:theme="@style/NPSTheme.Splash"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name=".activities.MainActivity"
|
||||
android:configChanges="screenSize|screenLayout|keyboardHidden|orientation">
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".data.download.DownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"
|
||||
/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.illusionware.npsbrowser
|
||||
|
||||
import com.github.wrdlbrnft.sortedlistadapter.SortedListAdapter
|
||||
|
||||
data class AppData(
|
||||
val titleID: String?, val region: String?, val title: String?, val link: String?,
|
||||
val license: String?, val contentID: String?, val lastDateTime: String?,
|
||||
val fileSize: Number?, val sha256: String?, val minFW: String?
|
||||
) : SortedListAdapter.ViewModel {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as AppData
|
||||
|
||||
if (titleID != other.titleID) return false
|
||||
if (region != other.region) return false
|
||||
if (title != other.title) return false
|
||||
if (link != other.link) return false
|
||||
if (license != other.license) return false
|
||||
if (contentID != other.contentID) return false
|
||||
if (lastDateTime != other.lastDateTime) return false
|
||||
if (fileSize != other.fileSize) return false
|
||||
if (sha256 != other.sha256) return false
|
||||
if (minFW != other.minFW) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = titleID?.hashCode() ?: 0
|
||||
result = 31 * result + (region?.hashCode() ?: 0)
|
||||
result = 31 * result + (title?.hashCode() ?: 0)
|
||||
result = 31 * result + (link?.hashCode() ?: 0)
|
||||
result = 31 * result + (license?.hashCode() ?: 0)
|
||||
result = 31 * result + (contentID?.hashCode() ?: 0)
|
||||
result = 31 * result + (lastDateTime?.hashCode() ?: 0)
|
||||
result = 31 * result + (fileSize?.hashCode() ?: 0)
|
||||
result = 31 * result + (sha256?.hashCode() ?: 0)
|
||||
result = 31 * result + (minFW?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
override fun <T : Any> isSameModelAs(model: T): Boolean {
|
||||
if (model is AppData) {
|
||||
val other: AppData = model
|
||||
return "${other.titleID} ${other.title}" == "$titleID $title"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun <T : Any> isContentTheSameAs(model: T): Boolean {
|
||||
if (model is AppData) {
|
||||
val other: AppData = model
|
||||
return title?.equals(other.title) ?: (other.title == null)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
package com.illusionware.npsbrowser
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Downloading
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.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.Downloads
|
||||
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.readexProFamily
|
||||
import com.illusionware.npsbrowser.ui.theme.ColorAccent
|
||||
import com.illusionware.npsbrowser.ui.theme.ColorOnPrimaryLight
|
||||
import com.illusionware.npsbrowser.ui.theme.ColorTag
|
||||
import com.illusionware.npsbrowser.ui.theme.NPSBrowserTheme
|
||||
import com.illusionware.npsbrowser.ui.theme.Typography
|
||||
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
|
||||
|
||||
val PlaceholderColor = Color(0x1F888888)
|
||||
val redHatDisplayFamily = FontFamily(
|
||||
Font(R.font.red_hat_display_bold, FontWeight.Bold),
|
||||
)
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModel.Factory
|
||||
)
|
||||
val onboardingViewModel: OnboardingViewModel = viewModel(
|
||||
factory = OnboardingViewModel.Factory
|
||||
)
|
||||
val packageDetailsViewModel: PackageDetailsViewModel = viewModel(
|
||||
factory = PackageDetailsViewModel.Factory
|
||||
)
|
||||
|
||||
val appTheme = runBlocking { settingsViewModel.getAppTheme() }
|
||||
var seenOnboarding by remember { mutableStateOf(false) }
|
||||
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
val navController = rememberNavController()
|
||||
|
||||
runBlocking {
|
||||
seenOnboarding = onboardingViewModel.getOnboardingStatus()
|
||||
}
|
||||
|
||||
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) Routes.Home.route else Routes.Onboarding.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(300))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(300))
|
||||
},
|
||||
) {
|
||||
composable(Routes.Home.route) {
|
||||
HomePage(
|
||||
navigateToSettings = { navController.navigate(Routes.Settings.route) },
|
||||
settingsPrefs = settingsPrefs,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigateToDetailsScreen = { item ->
|
||||
packageDetailsViewModel.setSelectedPackage(item)
|
||||
navController.navigate(Routes.PackageDetails.route)
|
||||
},
|
||||
navigateToDownloads = {
|
||||
navController.navigate(Routes.Downloads.route)
|
||||
},
|
||||
packageDetailsViewModel = packageDetailsViewModel,
|
||||
)
|
||||
}
|
||||
composable(Routes.Settings.route) {
|
||||
SettingsScreen(
|
||||
navigationGoBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.Onboarding.route) {
|
||||
OnBoarding(
|
||||
navigateToHome = {
|
||||
navController.popBackStack()
|
||||
navController.navigate(Routes.Home.route)
|
||||
},
|
||||
darkTheme = darkTheme,
|
||||
)
|
||||
}
|
||||
composable(Routes.PackageDetails.route) {
|
||||
PackageDetails(
|
||||
navigationGoBack = { navController.popBackStack() },
|
||||
packageDetailsViewModel = packageDetailsViewModel,
|
||||
)
|
||||
}
|
||||
composable(Routes.Downloads.route) {
|
||||
Downloads(
|
||||
navigationGoBack = { navController.popBackStack() },
|
||||
packageDetailsViewModel = packageDetailsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomePage(
|
||||
navigateToSettings: () -> Unit,
|
||||
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
||||
navigateToDownloads: () -> Unit,
|
||||
settingsPrefs: SettingsPreferences,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
packageListViewModel: PackageListViewModel = viewModel(
|
||||
factory = PackageListViewModel.Factory
|
||||
),
|
||||
packageDetailsViewModel: PackageDetailsViewModel
|
||||
) {
|
||||
val searchBar = remember { FocusRequester() }
|
||||
var layoutDialogOpen by remember { mutableStateOf(false) }
|
||||
val uiState = packageListViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
val tsvs = settingsViewModel.availableTsvs()
|
||||
var selectedBarItem by rememberSaveable { mutableStateOf(ConsoleType.PSVITA) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (!uiState.isSearchOpen) {
|
||||
Text(text = stringResource(id = R.string.app_name))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (uiState.isSearchOpen) {
|
||||
NPSIconButton(
|
||||
tooltip = "Close Search",
|
||||
onClick = {
|
||||
packageListViewModel.closeSearch()
|
||||
}
|
||||
){
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Search")
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
TextField(
|
||||
value = uiState.searchQuery,
|
||||
onValueChange = {
|
||||
packageListViewModel.search(it)
|
||||
},
|
||||
Modifier
|
||||
.weight(1.0f)
|
||||
.focusRequester(searchBar),
|
||||
placeholder = {
|
||||
Text(text = stringResource(id = R.string.search), style = Typography.bodyLarge)
|
||||
},
|
||||
singleLine = true,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
if (uiState.searchQuery.isNotEmpty()) {
|
||||
NPSIconButton(
|
||||
tooltip = "Clear Search",
|
||||
onClick = {
|
||||
packageListViewModel.clearSearch()
|
||||
}
|
||||
){
|
||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "Clear Search")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
searchBar.requestFocus()
|
||||
}
|
||||
}
|
||||
if (!uiState.isSearchOpen) {
|
||||
NPSIconButton(
|
||||
tooltip = stringResource(id = R.string.search_tooltip),
|
||||
onClick = { packageListViewModel.openSearch() }
|
||||
){
|
||||
Icon(imageVector = Icons.Filled.Search, contentDescription = stringResource(
|
||||
id = R.string.search_tooltip
|
||||
))
|
||||
}
|
||||
}
|
||||
NPSIconButton(
|
||||
tooltip = stringResource(id = R.string.layout),
|
||||
onClick = { layoutDialogOpen = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (settingsPrefs.layout == ItemLayout.GRID.ordinal) Icons.Filled.GridView else Icons.AutoMirrored.Filled.ViewList,
|
||||
contentDescription = stringResource(
|
||||
id = R.string.layout
|
||||
)
|
||||
)
|
||||
}
|
||||
NPSIconButton(
|
||||
tooltip = "Download queue",
|
||||
onClick = navigateToDownloads,
|
||||
badge = {
|
||||
val count = packageDetailsViewModel.downloadManager.queueState.collectAsStateWithLifecycle().value.count()
|
||||
if (count > 0) {
|
||||
Badge(
|
||||
containerColor = ColorTag,
|
||||
contentColor = Color.Black,
|
||||
) {
|
||||
Text(
|
||||
text = count.toString(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = readexProFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Downloading, contentDescription = "Download queue")
|
||||
}
|
||||
NPSIconButton(
|
||||
tooltip = stringResource(id = R.string.title_activity_settings),
|
||||
onClick = { navigateToSettings() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = stringResource(
|
||||
id = R.string.title_activity_settings
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (tsvs.isNotEmpty()) {
|
||||
NavBar(
|
||||
selectedTab = selectedBarItem,
|
||||
onTabSelected = { selectedBarItem = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(Modifier.padding(padding)) {
|
||||
if (tsvs.isEmpty()) {
|
||||
NoTsvs(navigateToSettings = navigateToSettings)
|
||||
} else {
|
||||
PackageList(
|
||||
navigateToDetailsScreen = navigateToDetailsScreen,
|
||||
type = selectedBarItem,
|
||||
settingsPrefs = settingsPrefs,
|
||||
viewModel = packageListViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutDialogOpen) {
|
||||
LayoutDialog(
|
||||
onDismiss = { layoutDialogOpen = false },
|
||||
prefs = settingsPrefs,
|
||||
viewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NoResults() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "No results found", color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavBar(selectedTab: ConsoleType, onTabSelected: (ConsoleType) -> Unit) {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == ConsoleType.PSVITA,
|
||||
icon = { Icon(painterResource(id = R.drawable.vita_icon), contentDescription = "PS Vita") },
|
||||
label = { Text(text = "PS Vita") },
|
||||
onClick = {
|
||||
onTabSelected(ConsoleType.PSVITA)
|
||||
}
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == ConsoleType.PS3,
|
||||
icon = { Icon(painterResource(id = R.drawable.ps3_icon), contentDescription = "PS3") },
|
||||
label = { Text(text = "PS3") },
|
||||
onClick = {
|
||||
onTabSelected(ConsoleType.PS3)
|
||||
}
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == ConsoleType.PSP,
|
||||
icon = { Icon(painterResource(id = R.drawable.psp_icon), contentDescription = "PSP") },
|
||||
label = { Text(text = "PSP") },
|
||||
onClick = {
|
||||
onTabSelected(ConsoleType.PSP)
|
||||
}
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == ConsoleType.PSX,
|
||||
icon = { Icon(painterResource(id = R.drawable.psx_icon), contentDescription = "PSX") },
|
||||
label = { Text(text = "PSX") },
|
||||
onClick = {
|
||||
onTabSelected(ConsoleType.PSX)
|
||||
}
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == ConsoleType.PSM,
|
||||
icon = { Icon(painterResource(id = R.drawable.psm_icon), contentDescription = "PSM") },
|
||||
label = { Text(text = "PSM") },
|
||||
onClick = {
|
||||
onTabSelected(ConsoleType.PSM)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun NoTsvs(padding: PaddingValues = PaddingValues(0.dp), navigateToSettings: () -> Unit = {}) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ph_info),
|
||||
contentDescription = "Info",
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.size(92.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "You haven't added any TSVs",
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
style = Typography.bodyLarge
|
||||
)
|
||||
Row {
|
||||
Text(
|
||||
text = "Add your TSV files from the ",
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
style = Typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = "settings",
|
||||
color = ColorAccent,
|
||||
style = Typography.bodyLarge,
|
||||
modifier = Modifier.clickable {
|
||||
navigateToSettings()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PackageList(
|
||||
type: ConsoleType = ConsoleType.PSVITA,
|
||||
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
||||
viewModel: PackageListViewModel,
|
||||
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 (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
if (packages.isEmpty() && uiState.searchQuery.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 (packages.isEmpty() && uiState.searchQuery.isNotEmpty()) {
|
||||
NoResults()
|
||||
} else {
|
||||
if (settingsPrefs.layout == ItemLayout.LIST.ordinal) {
|
||||
LazyColumn {
|
||||
items(packages) {
|
||||
PackageListItem(it, navigateToDetailsScreen)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(140.dp),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
) {
|
||||
items(packages) {
|
||||
PackageGridItem(it, navigateToDetailsScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PackageListItem(
|
||||
item: PackageItem,
|
||||
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { navigateToDetailsScreen(item) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.weight(1.0f)
|
||||
) {
|
||||
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.size(48.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = item.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = Typography.bodyLarge,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Min)
|
||||
.weight(1.0f)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
|
||||
Text(text = item.region)
|
||||
if (!item.minFW.isNullOrEmpty()) {
|
||||
Text(text = item.minFW)
|
||||
}
|
||||
if (item.pkgSize.isNotEmpty()) {
|
||||
Text(text = item.pkgSize)
|
||||
}
|
||||
Tag(title = item.dataType.name, small = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column {
|
||||
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.isNullOrEmpty() && item.rap.isNullOrEmpty())) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.no_key_icon),
|
||||
contentDescription = "No License Key",
|
||||
tint = Color(0xFFCB5A5A)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Tag(title: String, small: Boolean = false) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(ColorTag)
|
||||
.layoutId("tag")
|
||||
.padding(horizontal = if (small) 6.dp else 8.dp, vertical = if (small) 2.dp else 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
color = ColorOnPrimaryLight,
|
||||
style = Typography.bodySmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = redHatDisplayFamily,
|
||||
fontSize = if (small) 8.sp else 12.sp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PackageGridItem(
|
||||
item: PackageItem,
|
||||
navigateToDetailsScreen: (item: PackageItem) -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { navigateToDetailsScreen(item) },
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
) {
|
||||
Layout(
|
||||
content = {
|
||||
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,
|
||||
)
|
||||
Tag(title = item.dataType.name)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val imagePlaceable = measurables[0].measure(constraints)
|
||||
val tagPlaceable = measurables.first { it.layoutId == "tag" }.measure(constraints)
|
||||
|
||||
layout(imagePlaceable.width, imagePlaceable.height) {
|
||||
imagePlaceable.placeRelative(0, 0)
|
||||
tagPlaceable.placeRelative(
|
||||
x = imagePlaceable.width - tagPlaceable.width - 16,
|
||||
y = 16,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
style = Typography.bodyLarge,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ProvideTextStyle(value = Typography.bodySmall.copy(color = MaterialTheme.colorScheme.outline)) {
|
||||
Text(text = item.region)
|
||||
if (!item.minFW.isNullOrEmpty()) {
|
||||
Text(text = item.minFW)
|
||||
}
|
||||
if (item.pkgSize.isNotEmpty()) {
|
||||
Text(text = item.pkgSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row {
|
||||
if (item.pkgUrl.isNullOrEmpty()) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.no_url_icon),
|
||||
contentDescription = "No PKG URL",
|
||||
tint = Color(0xFFCB5A5A)
|
||||
)
|
||||
}
|
||||
if (item.zRif.isNullOrEmpty() && item.rap.isNullOrEmpty()) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.no_key_icon),
|
||||
contentDescription = "No License Key",
|
||||
tint = Color(0xFFCB5A5A)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LayoutDialog(
|
||||
onDismiss: () -> Unit,
|
||||
prefs: SettingsPreferences,
|
||||
viewModel: SettingsViewModel
|
||||
) {
|
||||
NPSAlertDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(id = R.string.layout),
|
||||
buttons = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = "Cancel")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
NPSRadioButton(
|
||||
text = "List",
|
||||
selected = prefs.layout == ItemLayout.LIST.ordinal,
|
||||
onClick = { viewModel.setLayout(ItemLayout.LIST) },
|
||||
)
|
||||
NPSRadioButton(
|
||||
text = "Grid",
|
||||
selected = prefs.layout == ItemLayout.GRID.ordinal,
|
||||
onClick = { viewModel.setLayout(ItemLayout.GRID) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.illusionware.npsbrowser
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.illusionware.npsbrowser.data.OnboardingPreferencesRepository
|
||||
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
|
||||
import com.illusionware.npsbrowser.data.download.DownloadManager
|
||||
import com.illusionware.npsbrowser.data.notification.Notifications
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
val HMAC_KEY = ubyteArrayOf(
|
||||
0xE5U, 0xE2U, 0x78U, 0xAAU, 0x1EU, 0xE3U, 0x40U, 0x82U, 0xA0U, 0x88U, 0x27U, 0x9CU, 0x83U, 0xF9U, 0xBBU, 0xC8U,
|
||||
0x06U, 0x82U, 0x1CU, 0x52U, 0xF2U, 0xABU, 0x5DU, 0x2BU, 0x4AU, 0xBDU, 0x99U, 0x54U, 0x50U, 0x35U, 0x51U, 0x14U
|
||||
)
|
||||
|
||||
private const val PREFERENCES_NAME = "preferences"
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = PREFERENCES_NAME
|
||||
)
|
||||
|
||||
class NPSApp: Application() {
|
||||
lateinit var settingsPreferencesRepository: SettingsPreferencesRepository
|
||||
lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository
|
||||
lateinit var downloadManager: DownloadManager
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
settingsPreferencesRepository = SettingsPreferencesRepository(dataStore)
|
||||
onboardingPreferencesRepository = OnboardingPreferencesRepository(dataStore)
|
||||
// downloader = Downloader(this)
|
||||
downloadManager = DownloadManager(this)
|
||||
|
||||
Notifications.initialize(this)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.illusionware.npsbrowser
|
||||
|
||||
import android.content.Context
|
||||
import android.view.GestureDetector
|
||||
import android.view.GestureDetector.SimpleOnGestureListener
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener
|
||||
|
||||
class RecyclerTouchListener(context: Context?, recyclerView: RecyclerView,
|
||||
private val clickListener: ClickListener?) : OnItemTouchListener {
|
||||
interface ClickListener {
|
||||
fun onClick(view: View?, position: Int)
|
||||
fun onLongClick(view: View?, recyclerView: RecyclerView?, position: Int)
|
||||
}
|
||||
|
||||
private val gestureDetector: GestureDetector
|
||||
override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
|
||||
val child = rv.findChildViewUnder(e.x, e.y)
|
||||
if (child != null && clickListener != null && gestureDetector.onTouchEvent(e)) {
|
||||
clickListener.onClick(child, rv.getChildAdapterPosition(child))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {}
|
||||
override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {}
|
||||
|
||||
init {
|
||||
gestureDetector = GestureDetector(context, object : SimpleOnGestureListener() {
|
||||
override fun onSingleTapUp(e: MotionEvent): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
val child = recyclerView.findChildViewUnder(e.x, e.y)
|
||||
if (child != null && clickListener != null) {
|
||||
clickListener.onLongClick(child, recyclerView, recyclerView.getChildAdapterPosition(child))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.illusionware.npsbrowser
|
||||
|
||||
sealed class Routes(val route: String) {
|
||||
data object Home : Routes("home")
|
||||
data object Onboarding : Routes("onboarding")
|
||||
data object Settings : Routes("settings")
|
||||
data object PackageDetails : Routes("package_details")
|
||||
data object Downloads : Routes("downloads")
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package com.illusionware.npsbrowser
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.ListPreference
|
||||
|
||||
class ThemePreference : ListPreference {
|
||||
constructor(context : Context?, attrs : AttributeSet?, defStyleAttr : Int) : super(context!!, attrs, defStyleAttr)
|
||||
|
||||
constructor(context : Context?, attrs : AttributeSet?) : super(context!!, attrs)
|
||||
|
||||
constructor(context : Context?) : super(context!!)
|
||||
|
||||
override fun callChangeListener(newValue : Any?) : Boolean {
|
||||
AppCompatDelegate.setDefaultNightMode(when ((newValue as String).toInt()) {
|
||||
0 -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
1 -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
2 -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED
|
||||
})
|
||||
|
||||
return super.callChangeListener(newValue)
|
||||
}
|
||||
|
||||
override fun setValue(value: String?) {
|
||||
super.setValue(value)
|
||||
summary = value
|
||||
}
|
||||
|
||||
override fun setSummary(summary: CharSequence?) {
|
||||
super.setSummary(entry)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.illusionware.npsbrowser.activities
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.illusionware.npsbrowser.R
|
||||
|
||||
open class BaseActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var sharedPreferences : SharedPreferences
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
PreferenceManager.setDefaultValues(this, R.xml.root_preferences, false)
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val decor: View = window.decorView
|
||||
|
||||
AppCompatDelegate.setDefaultNightMode(when ((sharedPreferences.getString("app_theme", "2")?.toInt())) {
|
||||
0 -> {
|
||||
decor.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
|
||||
AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
1 -> {
|
||||
decor.systemUiVisibility = 0;
|
||||
AppCompatDelegate.MODE_NIGHT_YES
|
||||
}
|
||||
2 -> {
|
||||
decor.systemUiVisibility = if (resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) 0 else View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
else -> AppCompatDelegate.MODE_NIGHT_UNSPECIFIED
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.illusionware.npsbrowser.activities
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.illusionware.npsbrowser.R
|
||||
import com.illusionware.npsbrowser.fragments.mainactivity.MainActivityFragment
|
||||
|
||||
class MainActivity : BaseActivity(), FragmentManager.OnBackStackChangedListener {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setTheme(R.style.NPSTheme)
|
||||
setContentView(R.layout.activity_main)
|
||||
setSupportActionBar(findViewById(R.id.toolbar))
|
||||
supportFragmentManager.addOnBackStackChangedListener(this);
|
||||
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
showUpButton()
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, MainActivityFragment())
|
||||
.commitNow()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
supportFragmentManager.popBackStack()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun onBackStackChanged() {
|
||||
// enable Up button only if there are entries on the backstack
|
||||
if (supportFragmentManager.backStackEntryCount < 1) {
|
||||
hideUpButton()
|
||||
} else {
|
||||
showUpButton()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUpButton() {
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
private fun hideUpButton() {
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(false)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.illusionware.npsbrowser.activities
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
|
||||
class SplashActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
startActivity(Intent(this, MainActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) hideSystemUI()
|
||||
}
|
||||
|
||||
private fun hideSystemUI() {
|
||||
// Enables regular immersive mode.
|
||||
// For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE.
|
||||
// Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_IMMERSIVE
|
||||
// Set the content to appear under the system bars so that the
|
||||
// content doesn't resize when the system bars hide and show.
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
// Hide the nav bar and status bar
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package com.illusionware.npsbrowser.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import com.github.wrdlbrnft.modularadapter.ModularAdapter
|
||||
import com.github.wrdlbrnft.sortedlistadapter.SortedListAdapter
|
||||
import com.illusionware.npsbrowser.AppData
|
||||
import com.illusionware.npsbrowser.R
|
||||
import com.illusionware.npsbrowser.activities.MainActivity
|
||||
import com.illusionware.npsbrowser.fragments.SingleAppFragment
|
||||
import com.illusionware.npsbrowser.fragments.mainactivity.MainActivityFragment
|
||||
import java.util.Comparator
|
||||
|
||||
private val comparator: Comparator<AppData> = SortedListAdapter.ComparatorBuilder<AppData>()
|
||||
.setOrderForModel(AppData::class.java) { a, b -> a.title?.compareTo(b.title!!)!! }
|
||||
.build()
|
||||
|
||||
class AppAdapter(val context: Context) : SortedListAdapter<AppData>(context, AppData::class.java, comparator) {
|
||||
private val LIST_ITEM = 0
|
||||
private val GRID_ITEM = 1
|
||||
private var isSwitchView = true
|
||||
|
||||
inner class AppViewHolder(itemView: View) :
|
||||
SortedListAdapter.ViewHolder<AppData?>(itemView) {
|
||||
var appTitle: TextView = itemView.findViewById(R.id.appTitle)
|
||||
var appRegion: TextView = itemView.findViewById(R.id.appRegion)
|
||||
var appMinFW: TextView = itemView.findViewById(R.id.appMinFW)
|
||||
var appLastDate: TextView = itemView.findViewById(R.id.appLastDate)
|
||||
|
||||
override fun performBind(item: AppData) {
|
||||
appTitle.text = item.titleID + " | " + item.title
|
||||
appTitle.isSelected = true
|
||||
appRegion.text = item.region
|
||||
appMinFW.text = item.minFW
|
||||
appMinFW.background = null;
|
||||
|
||||
if (appMinFW.text.isNotBlank()) {
|
||||
val drawable = ResourcesCompat.getDrawable(
|
||||
context.resources,
|
||||
R.drawable.rect_red,
|
||||
null
|
||||
)
|
||||
appMinFW.background = drawable;
|
||||
}
|
||||
appLastDate.text = item.lastDateTime
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(inflater: LayoutInflater, parent: ViewGroup, viewType: Int): AppViewHolder {
|
||||
return if (viewType == LIST_ITEM) {
|
||||
AppViewHolder(
|
||||
inflater.inflate(
|
||||
R.layout.recycler_item_list,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
} else {
|
||||
AppViewHolder(
|
||||
inflater.inflate(
|
||||
R.layout.recycler_item_grid,
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (isSwitchView) {
|
||||
LIST_ITEM
|
||||
} else {
|
||||
GRID_ITEM
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleItemViewType(type: Int): Boolean {
|
||||
isSwitchView = type == 0
|
||||
return isSwitchView
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.illusionware.npsbrowser.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
data class OnboardingPreferences(
|
||||
val seenOnboarding: Boolean,
|
||||
)
|
||||
|
||||
class OnboardingPreferencesRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
companion object {
|
||||
private val SEEN_ONBOARDING = booleanPreferencesKey("seen_onboarding")
|
||||
}
|
||||
|
||||
fun getPreferences(): Flow<OnboardingPreferences> =
|
||||
runBlocking { dataStore.data.map { mapOnboardingSettings(it) } }
|
||||
|
||||
suspend fun setSeenOnboarding(seen: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[SEEN_ONBOARDING] = seen
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapOnboardingSettings(prefs: Preferences): OnboardingPreferences {
|
||||
return OnboardingPreferences(
|
||||
prefs[SEEN_ONBOARDING] ?: false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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 downloadDir: String,
|
||||
val unpackDir: String,
|
||||
val unpackInDownload: Boolean,
|
||||
val deleteAfterUnpack: Boolean,
|
||||
val pkg2zipParams: String,
|
||||
val pkg2zipAutoDecrypt: Boolean,
|
||||
)
|
||||
|
||||
class SettingsPreferencesRepository(
|
||||
private val dataStore: DataStore<Preferences>
|
||||
) {
|
||||
companion object {
|
||||
private val APP_THEME = intPreferencesKey("app_theme")
|
||||
private val LAYOUT = intPreferencesKey("layout")
|
||||
private val PSV_GAMES = stringPreferencesKey("psv_games")
|
||||
private val PS3_GAMES = stringPreferencesKey("ps3_games")
|
||||
private val PSP_GAMES = stringPreferencesKey("psp_games")
|
||||
private val PSX_GAMES = stringPreferencesKey("psx_games")
|
||||
private val PSM_GAMES = stringPreferencesKey("psm_games")
|
||||
private val PSV_DLC = stringPreferencesKey("psv_dlc")
|
||||
private val PS3_DLC = stringPreferencesKey("ps3_dlc")
|
||||
private val PSP_DLC = stringPreferencesKey("psp_dlc")
|
||||
private val PSV_THEMES = stringPreferencesKey("psv_themes")
|
||||
private val DOWNLOAD_DIR = stringPreferencesKey("download_dir")
|
||||
private val UNPACK_DIR = stringPreferencesKey("unpack_dir")
|
||||
private val UNPACK_IN_DOWNLOAD = booleanPreferencesKey("unpack_in_download")
|
||||
private val DELETE_AFTER_UNPACK = booleanPreferencesKey("delete_after_unpack")
|
||||
private val PKG2ZIP_PARAMS = stringPreferencesKey("pkg2zip_params")
|
||||
private val PKG2ZIP_AUTO_DECRYPT = booleanPreferencesKey("pkg2zip_auto_decrypt")
|
||||
}
|
||||
|
||||
private val tsvFiles = MutableSharedFlow<Triple<String, ConsoleType, PackageItemType>>()
|
||||
|
||||
fun getPreferences(): Flow<SettingsPreferences> =
|
||||
dataStore.data.map { mapSettingsPreferences(it) }
|
||||
|
||||
suspend fun availableTsvs(): MutableList<Triple<String, ConsoleType, PackageItemType>> {
|
||||
val list = mutableListOf<Triple<String, ConsoleType, PackageItemType>>()
|
||||
|
||||
return dataStore.data.map {
|
||||
if (it[PSV_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSV_GAMES] ?: "", ConsoleType.PSVITA, PackageItemType.GAME))
|
||||
if (it[PSV_DLC]?.isNotEmpty() == true) list.add(Triple(it[PSV_DLC] ?: "", ConsoleType.PSVITA, PackageItemType.DLC))
|
||||
if (it[PSV_THEMES]?.isNotEmpty() == true) list.add(Triple(it[PSV_THEMES] ?: "", ConsoleType.PSVITA, PackageItemType.THEME))
|
||||
if (it[PS3_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PS3_GAMES] ?: "", ConsoleType.PS3, PackageItemType.GAME))
|
||||
if (it[PS3_DLC]?.isNotEmpty() == true) list.add(Triple(it[PS3_DLC] ?: "", ConsoleType.PS3, PackageItemType.DLC))
|
||||
if (it[PSP_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSP_GAMES] ?: "", ConsoleType.PSP, PackageItemType.GAME))
|
||||
if (it[PSP_DLC]?.isNotEmpty() == true) list.add(Triple(it[PSP_DLC] ?: "", ConsoleType.PSP, PackageItemType.DLC))
|
||||
if (it[PSX_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSX_GAMES] ?: "", ConsoleType.PSX, PackageItemType.GAME))
|
||||
if (it[PSM_GAMES]?.isNotEmpty() == true) list.add(Triple(it[PSM_GAMES] ?: "", ConsoleType.PSM, PackageItemType.GAME))
|
||||
|
||||
list
|
||||
}.first()
|
||||
}
|
||||
|
||||
suspend fun setTheme(theme: Theme) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[APP_THEME] = theme.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setLayout(layout: ItemLayout) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[LAYOUT] = layout.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun 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[DOWNLOAD_DIR] ?: defaultDownloadDir,
|
||||
prefs[UNPACK_DIR] ?: defaultDownloadDir,
|
||||
prefs[UNPACK_IN_DOWNLOAD] ?: false,
|
||||
prefs[DELETE_AFTER_UNPACK] ?: false,
|
||||
prefs[PKG2ZIP_PARAMS] ?: "-x {pkgFile} \"{zRifKey}\"",
|
||||
prefs[PKG2ZIP_AUTO_DECRYPT] ?: true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.illusionware.npsbrowser.data.download
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
|
||||
class DownloadManager(private val context: Context) {
|
||||
private val downloader = Downloader(context, this)
|
||||
|
||||
val queueState
|
||||
get() = downloader.queueState
|
||||
|
||||
fun downloaderStart() = downloader.start()
|
||||
fun downloaderStop(reason: String? = null) = downloader.stop(reason)
|
||||
|
||||
val isDownloaderRunning
|
||||
get() = DownloadService.isRunning
|
||||
val isDownloaderPaused
|
||||
get() = downloader.isPaused
|
||||
|
||||
fun startDownloads() {
|
||||
if (DownloadService.isRunning(context)) {
|
||||
return
|
||||
}
|
||||
DownloadService.start(context, this)
|
||||
}
|
||||
|
||||
fun pauseDownload(url: String) = downloader.pause(url)
|
||||
fun resumeDownload(url: String) = downloader.resume(url)
|
||||
|
||||
fun pauseDownloads() {
|
||||
downloader.pauseAll()
|
||||
downloader.stop()
|
||||
}
|
||||
|
||||
fun shouldConfirmOverwrite(url: String): Boolean {
|
||||
return downloader.shouldConfirmOverwrite(url)
|
||||
}
|
||||
|
||||
fun download(package_: NPSPackageDownload, autoStart: Boolean = true, overwriteExisting: Boolean = false) {
|
||||
downloader.enqueue(package_, autoStart, overwriteExisting)
|
||||
}
|
||||
|
||||
fun cancelDownload(url: String) {
|
||||
val wasRunning = downloader.isRunning
|
||||
// if (wasRunning) {
|
||||
// downloader.pauseAll()
|
||||
// }
|
||||
|
||||
downloader.removeDownload(url)
|
||||
|
||||
if (wasRunning && queueState.value.isEmpty()) {
|
||||
downloader.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun statusFlow(): Flow<NPSPackageDownload> = queueState
|
||||
.flatMapLatest { downloads ->
|
||||
downloads
|
||||
.map { download ->
|
||||
download.statusFlow.drop(1).map { download }
|
||||
}
|
||||
.merge()
|
||||
}
|
||||
.onStart {
|
||||
emitAll(queueState.value.filter { download -> download.status == NPSPackageDownload.State.DOWNLOADING }.asFlow())
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun progressFlow(): Flow<NPSPackageDownload> = queueState
|
||||
.flatMapLatest { downloads ->
|
||||
downloads
|
||||
.map { download ->
|
||||
download.progressFlow.drop(1).map { download }
|
||||
}
|
||||
.merge()
|
||||
}
|
||||
.onStart {
|
||||
emitAll(queueState.value.filter { download -> download.status == NPSPackageDownload.State.DOWNLOADING }.asFlow())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.illusionware.npsbrowser.data.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.illusionware.npsbrowser.R
|
||||
import com.illusionware.npsbrowser.data.notification.Notifications
|
||||
import com.illusionware.npsbrowser.util.system.cancelNotification
|
||||
import com.illusionware.npsbrowser.util.system.notificationBuilder
|
||||
import com.illusionware.npsbrowser.util.system.notify
|
||||
|
||||
/**
|
||||
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
|
||||
*
|
||||
* @param context context of application
|
||||
*/
|
||||
internal class DownloadNotifier(private val context: Context) {
|
||||
|
||||
private val progressNotificationBuilder by lazy {
|
||||
context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) {
|
||||
setSmallIcon(R.drawable.baseline_cloud_download_24)
|
||||
setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
setAutoCancel(false)
|
||||
setOnlyAlertOnce(true)
|
||||
setSilent(true)
|
||||
setOngoing(true)
|
||||
setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
}
|
||||
}
|
||||
|
||||
private val errorNotificationBuilder by lazy {
|
||||
context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) {
|
||||
setSmallIcon(R.drawable.baseline_cloud_download_24)
|
||||
setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
setAutoCancel(true)
|
||||
setSilent(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of download. Used for correct notification icon.
|
||||
*/
|
||||
private var isDownloading = false
|
||||
|
||||
/**
|
||||
* Shows a notification from this builder.
|
||||
*
|
||||
* @param id the id of the notification.
|
||||
*/
|
||||
private fun NotificationCompat.Builder.show(id: Int) {
|
||||
context.notify(id, build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
|
||||
* those can only be dismissed by the user.
|
||||
*/
|
||||
private fun dismissProgress() {
|
||||
context.cancelNotification(Notifications.ID_DOWNLOADER_GROUP)
|
||||
}
|
||||
|
||||
private fun dismissError() {
|
||||
context.cancelNotification(Notifications.ID_DOWNLOADER_ERROR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when download progress changes.
|
||||
*
|
||||
* @param download download object containing download information.
|
||||
*/
|
||||
fun onProgressChange(size: Long, progress: Long) {
|
||||
with(progressNotificationBuilder) {
|
||||
if (!isDownloading) {
|
||||
clearActions()
|
||||
isDownloading = true
|
||||
}
|
||||
|
||||
setContentTitle("Downloading packages")
|
||||
setContentText("${progress / 1024 / 1024}MB / ${size / 1024 / 1024}MB")
|
||||
setProgress((size / 1024).toInt(), (progress / 1024).toInt(), false)
|
||||
dismissError()
|
||||
|
||||
show(Notifications.ID_DOWNLOADER_GROUP)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPaused(reason: String? = null) {
|
||||
with(progressNotificationBuilder) {
|
||||
setContentTitle("Downloads paused")
|
||||
setContentText(reason ?: "Waiting to resume downloads")
|
||||
setProgress(0, 0, false)
|
||||
setOngoing(false)
|
||||
|
||||
show(Notifications.ID_DOWNLOADER_GROUP)
|
||||
}
|
||||
|
||||
isDownloading = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the state once downloads are completed.
|
||||
*/
|
||||
fun onComplete() {
|
||||
dismissProgress()
|
||||
dismissError()
|
||||
|
||||
// Reset states to default
|
||||
isDownloading = false
|
||||
}
|
||||
|
||||
fun onError(error: String) {
|
||||
with(errorNotificationBuilder) {
|
||||
setContentTitle("Download issue")
|
||||
setStyle(NotificationCompat.BigTextStyle().bigText(error))
|
||||
setContentText(error)
|
||||
setProgress(0, 0, false)
|
||||
|
||||
show(Notifications.ID_DOWNLOADER_ERROR)
|
||||
}
|
||||
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.illusionware.npsbrowser.data.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class DownloadRepository(private val downloadDAO: NPSPackageDownloadDao) {
|
||||
val allDownloads = downloadDAO.getAll()
|
||||
|
||||
@WorkerThread
|
||||
suspend fun insert(download: NPSPackageDownload) {
|
||||
downloadDAO.insert(download)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun delete(url: String) {
|
||||
downloadDAO.delete(url)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
suspend fun deleteAll() {
|
||||
downloadDAO.deleteAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.illusionware.npsbrowser.data.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.Service
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager.WakeLock
|
||||
import android.util.Log
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.illusionware.npsbrowser.R
|
||||
import com.illusionware.npsbrowser.data.notification.Notifications
|
||||
import com.illusionware.npsbrowser.util.system.acquireWakeLock
|
||||
import com.illusionware.npsbrowser.util.system.isOnline
|
||||
import com.illusionware.npsbrowser.util.system.isServiceRunning
|
||||
import com.illusionware.npsbrowser.util.system.notificationBuilder
|
||||
import com.illusionware.npsbrowser.util.system.toast
|
||||
import com.illusionware.npsbrowser.util.system.withUIContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import ru.beryukhov.reactivenetwork.ReactiveNetwork
|
||||
|
||||
class DownloadService : Service() {
|
||||
companion object {
|
||||
private lateinit var _downloadManager: DownloadManager
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning = _isRunning.asStateFlow()
|
||||
|
||||
fun start(context: Context, downloadManager: DownloadManager) {
|
||||
Log.d("DOWNLOAD", "Starting download service")
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
_downloadManager = downloadManager
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, DownloadService::class.java))
|
||||
}
|
||||
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return context.isServiceRunning(DownloadService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var wakeLock: WakeLock
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
override fun onCreate() {
|
||||
Log.d("DOWNLOAD", "In onCreate, acquring wakelock")
|
||||
super.onCreate()
|
||||
scope = CoroutineScope(Dispatchers.IO)
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
Notifications.ID_DOWNLOADER_GROUP,
|
||||
getPlaceholderNotification(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
wakeLock = acquireWakeLock(javaClass.name)
|
||||
_isRunning.value = true
|
||||
listenNetworkChanges()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
scope.cancel()
|
||||
_isRunning.value = false
|
||||
_downloadManager.downloaderStop()
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun downloaderStop(string: String) {
|
||||
_downloadManager.downloaderStop(string)
|
||||
}
|
||||
|
||||
private fun listenNetworkChanges() {
|
||||
Log.d("DOWNLOAD", "Listening to network changes")
|
||||
ReactiveNetwork()
|
||||
.observeNetworkConnectivity(applicationContext)
|
||||
.onEach {
|
||||
withUIContext {
|
||||
if (isOnline()) {
|
||||
Log.d("DOWNLOAD", "We're online so we start the downloader")
|
||||
val started = _downloadManager.downloaderStart()
|
||||
if (!started) stopSelf()
|
||||
} else {
|
||||
downloaderStop("Not connected to the internet")
|
||||
}
|
||||
}
|
||||
}
|
||||
.catch { error ->
|
||||
withUIContext {
|
||||
// logcat(LogPriority.ERROR, error)
|
||||
toast("Download failed: $error")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
private fun getPlaceholderNotification(): Notification {
|
||||
return notificationBuilder(Notifications.CHANNEL_DOWNLOADER) {
|
||||
setSmallIcon(R.drawable.baseline_cloud_download_24)
|
||||
setCategory(androidx.core.app.NotificationCompat.CATEGORY_SERVICE)
|
||||
setOnlyAlertOnce(true)
|
||||
setOngoing(true)
|
||||
setSilent(true)
|
||||
setContentTitle("Preparing downloads")
|
||||
setContentText("Starting downloader service")
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.illusionware.npsbrowser.data.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.Update
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.illusionware.npsbrowser.data.network.ProgressListener
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
private const val DOWNLOAD_STORE_NAME = "downloads"
|
||||
private const val DOWNLOAD_TABLE_NAME = "download"
|
||||
|
||||
@Entity(tableName = DOWNLOAD_TABLE_NAME)
|
||||
data class NPSPackageDownload(
|
||||
@PrimaryKey @ColumnInfo(name = "url")
|
||||
val url: String,
|
||||
val titleId: String,
|
||||
val name: String,
|
||||
val sha256: String?,
|
||||
val size: Long,
|
||||
var bytesDownloaded: Long,
|
||||
val createdAt: Long,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
var statusValue: Int = State.PAUSED.value,
|
||||
var savedFileName: String? = null,
|
||||
): ProgressListener {
|
||||
@Transient
|
||||
private val _statusFlow = MutableStateFlow(State.PAUSED)
|
||||
|
||||
@Transient
|
||||
val statusFlow = _statusFlow.asStateFlow()
|
||||
var status: State
|
||||
get() = _statusFlow.value
|
||||
set(status) {
|
||||
statusValue = status.value
|
||||
_statusFlow.value = status
|
||||
}
|
||||
|
||||
@Transient
|
||||
private val _progressFlow = MutableStateFlow(
|
||||
if (size > 0) bytesDownloaded.toFloat() / size.toFloat() else 0f
|
||||
)
|
||||
|
||||
@Transient
|
||||
var progressFlow = _progressFlow.asStateFlow()
|
||||
private var progress: Float
|
||||
get() = _progressFlow.value
|
||||
set(value) {
|
||||
_progressFlow.value = value
|
||||
}
|
||||
|
||||
@Transient
|
||||
private val _bytesReadFlow = MutableStateFlow(bytesDownloaded)
|
||||
|
||||
@Transient
|
||||
var bytesDownloadedFlow = _bytesReadFlow.asStateFlow()
|
||||
private var bytesRead: Long
|
||||
get() = _bytesReadFlow.value
|
||||
set(value) {
|
||||
_bytesReadFlow.value = value
|
||||
}
|
||||
|
||||
|
||||
enum class State(val value: Int) {
|
||||
PAUSED(0),
|
||||
QUEUE(1),
|
||||
DOWNLOADING(2),
|
||||
DOWNLOADED(3),
|
||||
ERROR(4),
|
||||
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): State = entries.firstOrNull { it.value == value } ?: PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreTransientState() {
|
||||
_statusFlow.value = State.fromValue(statusValue)
|
||||
_progressFlow.value = if (size > 0) {
|
||||
(bytesDownloaded.toFloat() / size.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
_bytesReadFlow.value = bytesDownloaded
|
||||
}
|
||||
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
progress = if (size > 0) {
|
||||
(bytesRead.toFloat() / size.toFloat()).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0.0f
|
||||
}
|
||||
|
||||
bytesDownloaded = bytesRead
|
||||
this.bytesRead = bytesRead
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface NPSPackageDownloadDao {
|
||||
@Query("SELECT * FROM download ORDER BY createdAt ASC")
|
||||
fun getAll(): Flow<List<NPSPackageDownload>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(download: NPSPackageDownload)
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun update(download: NPSPackageDownload)
|
||||
|
||||
@Query("DELETE FROM download WHERE url = :url")
|
||||
suspend fun delete(url: String)
|
||||
|
||||
@Query("DELETE FROM download")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@Database(entities = [NPSPackageDownload::class], version = 2)
|
||||
abstract class DownloadStore : RoomDatabase() {
|
||||
abstract fun downloadDao(): NPSPackageDownloadDao
|
||||
|
||||
companion object {
|
||||
private val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE $DOWNLOAD_TABLE_NAME ADD COLUMN statusValue INTEGER NOT NULL DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE $DOWNLOAD_TABLE_NAME ADD COLUMN savedFileName TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: DownloadStore? = null
|
||||
|
||||
fun getDatabase(context: Context): DownloadStore {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
DownloadStore::class.java,
|
||||
DOWNLOAD_STORE_NAME
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.illusionware.npsbrowser.data.download
|
||||
|
||||
import android.content.Context
|
||||
import com.illusionware.npsbrowser.data.defaultDownloadDir
|
||||
import com.illusionware.npsbrowser.data.network.GET
|
||||
import com.illusionware.npsbrowser.data.network.NetworkHelper
|
||||
import com.illusionware.npsbrowser.data.network.ProgressListener
|
||||
import com.illusionware.npsbrowser.data.network.awaitSuccess
|
||||
import com.illusionware.npsbrowser.data.network.newCachelessCallWithProgress
|
||||
import com.illusionware.npsbrowser.util.system.saveTo
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Headers
|
||||
import okio.appendingSink
|
||||
import okio.buffer
|
||||
import java.io.File
|
||||
class Downloader(val context: Context, private val downloadManager: DownloadManager) {
|
||||
|
||||
private val invalidFilenameChars = Regex("[\\\\/:*?\"<>|]")
|
||||
private val whitespaceRegex = Regex("\\s+")
|
||||
|
||||
private val store = DownloadStore.getDatabase(context)
|
||||
private val networkHelper = NetworkHelper(context)
|
||||
private val dao = store.downloadDao()
|
||||
|
||||
private val _queueState = MutableStateFlow<List<NPSPackageDownload>>(emptyList())
|
||||
val queueState = _queueState.asStateFlow()
|
||||
|
||||
private val queueMutex = Mutex()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private var workerJob: Job? = null
|
||||
private var currentDownloadJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var currentDownloadUrl: String? = null
|
||||
|
||||
private val notifier by lazy { DownloadNotifier(context) }
|
||||
|
||||
val isRunning: Boolean
|
||||
get() = workerJob?.isActive ?: false
|
||||
|
||||
@Volatile
|
||||
var isPaused: Boolean = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
val storeItems = dao.getAll().first()
|
||||
addAllToQueue(storeItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldConfirmOverwrite(url: String): Boolean {
|
||||
val existing = queueState.value.find { it.url == url } ?: return false
|
||||
return existing.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(existing).exists()
|
||||
}
|
||||
|
||||
fun enqueue(download: NPSPackageDownload, autoStart: Boolean, overwriteExisting: Boolean) {
|
||||
scope.launch {
|
||||
val shouldInsert = queueMutex.withLock {
|
||||
val queue = _queueState.value.toMutableList()
|
||||
val existing = queue.find { it.url == download.url }
|
||||
if (existing != null && existing.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(existing).exists()) {
|
||||
if (!overwriteExisting) {
|
||||
_queueState.value = queue
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
downloadedFileFor(existing).delete()
|
||||
}
|
||||
|
||||
queue.removeAll { it.url == download.url }
|
||||
download.savedFileName = null
|
||||
download.bytesDownloaded = 0L
|
||||
download.status = NPSPackageDownload.State.QUEUE
|
||||
download.restoreTransientState()
|
||||
queue.add(download)
|
||||
_queueState.value = queue
|
||||
true
|
||||
}
|
||||
|
||||
if (!shouldInsert) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
dao.insert(download)
|
||||
|
||||
if (autoStart) {
|
||||
isPaused = false
|
||||
launchWorkerIfNeeded()
|
||||
if (!DownloadService.isRunning(context)) {
|
||||
DownloadService.start(context, downloadManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(): Boolean {
|
||||
if (isRunning) return true
|
||||
|
||||
val hasPending = queueState.value.any {
|
||||
it.status == NPSPackageDownload.State.QUEUE ||
|
||||
it.status == NPSPackageDownload.State.PAUSED ||
|
||||
it.status == NPSPackageDownload.State.ERROR
|
||||
}
|
||||
if (!hasPending) return false
|
||||
|
||||
scope.launch {
|
||||
queueMutex.withLock {
|
||||
val queue = _queueState.value.toMutableList()
|
||||
queue.forEach { download ->
|
||||
if (download.status == NPSPackageDownload.State.PAUSED || download.status == NPSPackageDownload.State.ERROR) {
|
||||
download.status = NPSPackageDownload.State.QUEUE
|
||||
}
|
||||
}
|
||||
_queueState.value = queue
|
||||
}
|
||||
isPaused = false
|
||||
launchWorkerIfNeeded()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun pauseAll() {
|
||||
scope.launch {
|
||||
queueMutex.withLock {
|
||||
val queue = _queueState.value.toMutableList()
|
||||
queue.forEach { download ->
|
||||
if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) {
|
||||
download.status = NPSPackageDownload.State.PAUSED
|
||||
}
|
||||
}
|
||||
_queueState.value = queue
|
||||
}
|
||||
|
||||
currentDownloadJob?.cancel()
|
||||
workerJob?.cancel()
|
||||
currentDownloadJob = null
|
||||
workerJob = null
|
||||
isPaused = true
|
||||
notifier.onPaused()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(reason: String? = null) {
|
||||
scope.launch {
|
||||
queueMutex.withLock {
|
||||
val queue = _queueState.value.toMutableList()
|
||||
queue.forEach { download ->
|
||||
if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) {
|
||||
download.status = NPSPackageDownload.State.PAUSED
|
||||
}
|
||||
}
|
||||
_queueState.value = queue
|
||||
}
|
||||
|
||||
currentDownloadJob?.cancel()
|
||||
workerJob?.cancel()
|
||||
currentDownloadJob = null
|
||||
workerJob = null
|
||||
isPaused = queueState.value.isNotEmpty()
|
||||
if (reason != null) {
|
||||
notifier.onPaused(reason)
|
||||
} else if (isPaused) {
|
||||
notifier.onPaused()
|
||||
} else {
|
||||
notifier.onComplete()
|
||||
}
|
||||
|
||||
if (reason == null && DownloadService.isRunning.value) {
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pause(url: String) {
|
||||
scope.launch {
|
||||
var pauseCurrent = false
|
||||
queueMutex.withLock {
|
||||
val queue = _queueState.value.toMutableList()
|
||||
queue.forEach { download ->
|
||||
if (download.url == url) {
|
||||
if (download.status == NPSPackageDownload.State.DOWNLOADING || download.status == NPSPackageDownload.State.QUEUE) {
|
||||
download.status = NPSPackageDownload.State.PAUSED
|
||||
}
|
||||
pauseCurrent = currentDownloadUrl == url
|
||||
}
|
||||
}
|
||||
_queueState.value = queue
|
||||
}
|
||||
|
||||
if (pauseCurrent) {
|
||||
currentDownloadJob?.cancel()
|
||||
}
|
||||
|
||||
if (areAllPaused()) {
|
||||
workerJob?.cancel()
|
||||
workerJob = null
|
||||
isPaused = true
|
||||
notifier.onPaused()
|
||||
if (DownloadService.isRunning.value) {
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resume(url: String) {
|
||||
scope.launch {
|
||||
if (queueState.value.isEmpty()) return@launch
|
||||
|
||||
queueMutex.withLock {
|
||||
val queue = _queueState.value.toMutableList()
|
||||
val index = queue.indexOfFirst { it.url == url }
|
||||
if (index == -1) return@withLock
|
||||
|
||||
val target = queue.removeAt(index)
|
||||
if (target.status == NPSPackageDownload.State.DOWNLOADED && downloadedFileFor(target).exists()) {
|
||||
queue.add(index, target)
|
||||
_queueState.value = queue
|
||||
return@withLock
|
||||
}
|
||||
target.status = NPSPackageDownload.State.QUEUE
|
||||
// Prioritize explicit resume action so the tapped item starts next.
|
||||
queue.add(0, target)
|
||||
_queueState.value = queue
|
||||
}
|
||||
|
||||
isPaused = false
|
||||
launchWorkerIfNeeded()
|
||||
if (!DownloadService.isRunning(context)) {
|
||||
DownloadService.start(context, downloadManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchWorkerIfNeeded() {
|
||||
if (workerJob?.isActive == true) return
|
||||
|
||||
workerJob = scope.launch {
|
||||
while (true) {
|
||||
val nextDownload = queueMutex.withLock {
|
||||
_queueState.value.firstOrNull { it.status == NPSPackageDownload.State.QUEUE }
|
||||
} ?: break
|
||||
|
||||
currentDownloadUrl = nextDownload.url
|
||||
|
||||
val job = scope.launch {
|
||||
processDownload(nextDownload)
|
||||
}
|
||||
currentDownloadJob = job
|
||||
|
||||
job.join()
|
||||
currentDownloadJob = null
|
||||
currentDownloadUrl = null
|
||||
}
|
||||
|
||||
if (queueState.value.none { it.status == NPSPackageDownload.State.QUEUE || it.status == NPSPackageDownload.State.DOWNLOADING }) {
|
||||
notifier.onComplete()
|
||||
isPaused = queueState.value.isNotEmpty() && queueState.value.all { it.status == NPSPackageDownload.State.PAUSED }
|
||||
if (DownloadService.isRunning.value) {
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
}
|
||||
|
||||
workerJob = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processDownload(download: NPSPackageDownload) {
|
||||
try {
|
||||
queueMutex.withLock {
|
||||
download.status = NPSPackageDownload.State.DOWNLOADING
|
||||
_queueState.value = _queueState.value.toList()
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
downloadPackage(download, this)
|
||||
}
|
||||
|
||||
queueMutex.withLock {
|
||||
download.bytesDownloaded = download.size
|
||||
download.restoreTransientState()
|
||||
download.status = NPSPackageDownload.State.DOWNLOADED
|
||||
_queueState.value = _queueState.value.toList()
|
||||
}
|
||||
|
||||
dao.update(download)
|
||||
} catch (_: CancellationException) {
|
||||
dao.update(download)
|
||||
} catch (error: Throwable) {
|
||||
queueMutex.withLock {
|
||||
download.status = NPSPackageDownload.State.ERROR
|
||||
_queueState.value = _queueState.value.toList()
|
||||
}
|
||||
dao.update(download)
|
||||
notifier.onError(error.message ?: "Download failed")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadPackage(download: NPSPackageDownload, scope: CoroutineScope) {
|
||||
val file = prepareTargetFile(download)
|
||||
val resumedBytes = if (file.exists()) file.length() else 0L
|
||||
|
||||
if (download.bytesDownloaded != resumedBytes) {
|
||||
download.bytesDownloaded = resumedBytes
|
||||
download.restoreTransientState()
|
||||
dao.update(download)
|
||||
}
|
||||
|
||||
val startBytes = download.bytesDownloaded
|
||||
var lastPersist = 0L
|
||||
|
||||
val listener = object : ProgressListener {
|
||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
download.update(bytesRead, contentLength, done)
|
||||
|
||||
val totalSize = if (contentLength > 0) {
|
||||
contentLength + startBytes
|
||||
} else {
|
||||
download.size
|
||||
}
|
||||
notifier.onProgressChange(totalSize, bytesRead)
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (done || now - lastPersist >= 1000L) {
|
||||
lastPersist = now
|
||||
this@Downloader.scope.launch {
|
||||
dao.update(download)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val res = networkHelper.client.newCachelessCallWithProgress(
|
||||
GET(download.url, Headers.headersOf("Range", "bytes=${download.bytesDownloaded}-")),
|
||||
listener,
|
||||
download.bytesDownloaded
|
||||
).awaitSuccess()
|
||||
|
||||
file.parentFile?.mkdirs()
|
||||
val fileBufferedSink = file.appendingSink().buffer()
|
||||
|
||||
try {
|
||||
res.body!!.source().saveTo(
|
||||
fileBufferedSink,
|
||||
res.body!!.contentLength(),
|
||||
download.bytesDownloaded,
|
||||
scope
|
||||
)
|
||||
dao.update(download)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) {
|
||||
throw e
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
withContext(Dispatchers.IO) {
|
||||
fileBufferedSink.close()
|
||||
}
|
||||
res.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun areAllPaused(): Boolean {
|
||||
val unfinished = queueState.value.filter { it.status != NPSPackageDownload.State.DOWNLOADED }
|
||||
return unfinished.isNotEmpty() && unfinished.all { it.status == NPSPackageDownload.State.PAUSED }
|
||||
}
|
||||
|
||||
private fun addAllToQueue(downloads: List<NPSPackageDownload>) {
|
||||
_queueState.update {
|
||||
downloads.forEach { download ->
|
||||
download.restoreTransientState()
|
||||
when (download.status) {
|
||||
NPSPackageDownload.State.DOWNLOADING,
|
||||
NPSPackageDownload.State.QUEUE,
|
||||
-> download.status = NPSPackageDownload.State.PAUSED
|
||||
|
||||
NPSPackageDownload.State.DOWNLOADED -> {
|
||||
if (!downloadedFileFor(download).exists()) {
|
||||
download.savedFileName = null
|
||||
download.bytesDownloaded = 0L
|
||||
download.restoreTransientState()
|
||||
download.status = NPSPackageDownload.State.ERROR
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
downloads
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDownload(url: String) {
|
||||
scope.launch {
|
||||
val removed = queueMutex.withLock {
|
||||
val queue = _queueState.value.toMutableList()
|
||||
val item = queue.find { it.url == url } ?: return@withLock null
|
||||
queue.remove(item)
|
||||
_queueState.value = queue
|
||||
item
|
||||
} ?: return@launch
|
||||
|
||||
if (currentDownloadUrl == removed.url) {
|
||||
currentDownloadJob?.cancel()
|
||||
}
|
||||
|
||||
dao.delete(url)
|
||||
|
||||
val file = downloadedFileFor(removed)
|
||||
file.delete()
|
||||
|
||||
if (queueState.value.isEmpty()) {
|
||||
workerJob?.cancel()
|
||||
workerJob = null
|
||||
currentDownloadJob = null
|
||||
notifier.onComplete()
|
||||
if (DownloadService.isRunning.value) {
|
||||
DownloadService.stop(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareTargetFile(download: NPSPackageDownload): File {
|
||||
val existingFileName = download.savedFileName
|
||||
if (existingFileName != null) {
|
||||
return File(defaultDownloadDir, existingFileName)
|
||||
}
|
||||
|
||||
val candidate = File(defaultDownloadDir, download.defaultFileName())
|
||||
|
||||
download.savedFileName = candidate.name
|
||||
return candidate
|
||||
}
|
||||
|
||||
private fun downloadedFileFor(download: NPSPackageDownload): File {
|
||||
return File(defaultDownloadDir, download.savedFileName ?: download.defaultFileName())
|
||||
}
|
||||
|
||||
private fun NPSPackageDownload.defaultFileName(): String {
|
||||
val normalizedName = name
|
||||
.trim()
|
||||
.replace(invalidFilenameChars, "_")
|
||||
.replace(whitespaceRegex, " ")
|
||||
.replace(Regex("\\.pkg$", RegexOption.IGNORE_CASE), "")
|
||||
.trimEnd('.', ' ')
|
||||
.ifBlank { titleId }
|
||||
|
||||
return "[$titleId] $normalizedName.pkg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.illusionware.npsbrowser.data.network
|
||||
|
||||
import android.content.Context
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
class NetworkHelper(context: Context) {
|
||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||
|
||||
private val baseClientBuilder: OkHttpClient.Builder
|
||||
get() {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.callTimeout(2, TimeUnit.MINUTES)
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
val client by lazy { baseClientBuilder.cache(Cache(cacheDir, cacheSize)).build() }
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.illusionware.npsbrowser.data.network
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import rx.Producer
|
||||
import rx.Subscription
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
fun Call.asObservable(): Observable<Response> {
|
||||
return Observable.unsafeCreate { subscriber ->
|
||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||
val call = clone()
|
||||
|
||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
||||
override fun request(n: Long) {
|
||||
if (n == 0L || !compareAndSet(false, true)) return
|
||||
|
||||
try {
|
||||
val response = call.execute()
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onNext(response)
|
||||
subscriber.onCompleted()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!subscriber.isUnsubscribed) {
|
||||
subscriber.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unsubscribe() {
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
override fun isUnsubscribed(): Boolean {
|
||||
return call.isCanceled()
|
||||
}
|
||||
}
|
||||
|
||||
subscriber.add(requestArbiter)
|
||||
subscriber.setProducer(requestArbiter)
|
||||
}
|
||||
}
|
||||
|
||||
fun Call.asObservableSuccess(): Observable<Response> {
|
||||
return asObservable().doOnNext { response ->
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Based on https://github.com/gildor/kotlin-coroutines-okhttp
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun Call.await(callStack: Array<StackTraceElement>): Response {
|
||||
Log.d("DOWNLOAD", "inside the await we want")
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback =
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response) {
|
||||
response.body?.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
// Don't bother with resuming the continuation if it is already cancelled.
|
||||
if (continuation.isCancelled) return
|
||||
val exception = IOException(e.message, e).apply { stackTrace = callStack }
|
||||
continuation.resumeWithException(exception)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(callback)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
try {
|
||||
Log.d("DOWNLOAD", "request is being cancelled")
|
||||
cancel()
|
||||
} catch (ex: Throwable) {
|
||||
// Ignore cancel exception
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Call.await(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
return await(callStack)
|
||||
}
|
||||
|
||||
suspend fun Call.awaitSuccess(): Response {
|
||||
val callStack = Exception().stackTrace.run { copyOfRange(1, size) }
|
||||
val response = await(callStack)
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw HttpException(response.code).apply { stackTrace = callStack }
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
fun OkHttpClient.newCachelessCallWithProgress(
|
||||
request: Request,
|
||||
listener: ProgressListener,
|
||||
bytesRead: Long
|
||||
): Call {
|
||||
val progressClient = newBuilder()
|
||||
.cache(null)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(originalResponse.body!!, listener, bytesRead))
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
return progressClient.newCall(request)
|
||||
}
|
||||
|
||||
class HttpException(private val code: Int) : IllegalStateException("HTTP error $code")
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.illusionware.npsbrowser.data.network
|
||||
|
||||
interface ProgressListener {
|
||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.illusionware.npsbrowser.data.network
|
||||
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import okio.ForwardingSource
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.io.IOException
|
||||
|
||||
class ProgressResponseBody(
|
||||
private val responseBody: ResponseBody,
|
||||
private val progressListener: ProgressListener,
|
||||
private val bytesRead: Long
|
||||
) : ResponseBody() {
|
||||
|
||||
private val bufferedSource: BufferedSource by lazy {
|
||||
source(responseBody.source()).buffer()
|
||||
}
|
||||
|
||||
override fun contentType(): MediaType? {
|
||||
return responseBody.contentType()
|
||||
}
|
||||
|
||||
override fun contentLength(): Long {
|
||||
return responseBody.contentLength()
|
||||
}
|
||||
|
||||
override fun source(): BufferedSource {
|
||||
return bufferedSource
|
||||
}
|
||||
|
||||
private fun source(source: Source): Source {
|
||||
return object : ForwardingSource(source) {
|
||||
var totalBytesRead = bytesRead
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||
val bytesRead = super.read(sink, byteCount)
|
||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||
return bytesRead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.illusionware.npsbrowser.data.network
|
||||
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, TimeUnit.MINUTES).build()
|
||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||
|
||||
fun GET(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return GET(url.toHttpUrl(), headers, cache)
|
||||
}
|
||||
|
||||
fun GET(
|
||||
url: HttpUrl,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.illusionware.npsbrowser.data.notification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationChannelGroupCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
object Notifications {
|
||||
private const val GROUP_DOWNLOADER = "group_downloader"
|
||||
const val CHANNEL_DOWNLOADER = "channel_downloader"
|
||||
const val ID_DOWNLOADER_GROUP = 1
|
||||
const val ID_DOWNLOADER_ERROR = 2
|
||||
|
||||
fun initialize(context: Context) {
|
||||
val notificationManager = NotificationManagerCompat.from(context)
|
||||
|
||||
notificationManager.createNotificationChannelGroup(
|
||||
NotificationChannelGroupCompat.Builder(GROUP_DOWNLOADER)
|
||||
.setName("Download")
|
||||
.build()
|
||||
)
|
||||
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannelCompat.Builder(CHANNEL_DOWNLOADER, NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName("Progress")
|
||||
.setDescription("Download progress and downloader status")
|
||||
.setShowBadge(false)
|
||||
.setGroup(GROUP_DOWNLOADER)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.illusionware.npsbrowser.fragments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentTransaction.TRANSIT_FRAGMENT_OPEN
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.illusionware.npsbrowser.R
|
||||
|
||||
class SettingsFragment : Fragment() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
PreferenceManager.setDefaultValues(requireContext(), R.xml.root_preferences, false)
|
||||
activity?.supportFragmentManager
|
||||
?.beginTransaction()
|
||||
?.replace(
|
||||
R.id.prefs,
|
||||
PrefsFragment()
|
||||
)
|
||||
?.setTransition(TRANSIT_FRAGMENT_OPEN)
|
||||
?.commit()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
activity?.title = resources.getString(R.string.title_activity_settings)
|
||||
return inflater.inflate(R.layout.settings_fragment, container, false)
|
||||
}
|
||||
|
||||
class PrefsFragment : PreferenceFragmentCompat() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.root_preferences, rootKey)
|
||||
PreferenceManager.setDefaultValues(requireContext(), R.xml.root_preferences, false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package com.illusionware.npsbrowser.fragments
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context.CLIPBOARD_SERVICE
|
||||
import android.content.Context.DOWNLOAD_SERVICE
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import com.bumptech.glide.Glide
|
||||
import com.illusionware.npsbrowser.AppData
|
||||
import com.illusionware.npsbrowser.R
|
||||
|
||||
class SingleAppFragment : Fragment() {
|
||||
private val baseStoreURL = "https://store.playstation.com/store/api/chihiro/00_09_000/container"
|
||||
|
||||
companion object {
|
||||
lateinit var app : AppData
|
||||
}
|
||||
|
||||
// TODO: either use binding or make vars for all the views
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
super.onCreateView(inflater, container, savedInstanceState)
|
||||
activity?.title = app.title
|
||||
val view = inflater.inflate(R.layout.single_app_fragment, container, false)
|
||||
val downloadManager = requireActivity().getSystemService(DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
view?.findViewById<TextView>(R.id.singleAppTitle)?.text = "[${app.titleID}] ${app.title}"
|
||||
if (app.fileSize != null) {
|
||||
view?.findViewById<TextView>(R.id.singleAppSize)?.text = prettifySize()
|
||||
} else {
|
||||
view?.findViewById<TextView>(R.id.singleAppSizeTitle)?.visibility = View.INVISIBLE
|
||||
}
|
||||
view?.findViewById<TextView>(R.id.singleAppContentId)?.text = app.contentID
|
||||
view?.findViewById<TextView>(R.id.singleAppFwVer)?.text = app.minFW
|
||||
// TODO: get and set app icon
|
||||
if (app.minFW.isNullOrBlank()) {
|
||||
view?.findViewById<TextView>(R.id.singleAppFwVerTitle)?.visibility = View.INVISIBLE
|
||||
}
|
||||
if (app.license == "MISSING") {
|
||||
view?.findViewById<TextView>(R.id.singleAppError)?.text = getString(R.string.no_license)
|
||||
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.isEnabled = false
|
||||
}
|
||||
if (app.link == "CART ONLY") {
|
||||
view?.findViewById<TextView>(R.id.singleAppError)?.text = getString(R.string.cart_only)
|
||||
view?.findViewById<Button>(R.id.singleAppDownloadButton)?.isEnabled = false
|
||||
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.isEnabled = false
|
||||
} else if (app.link == "MISSING") {
|
||||
view?.findViewById<TextView>(R.id.singleAppError)?.text = getString(R.string.no_download_link)
|
||||
view?.findViewById<Button>(R.id.singleAppDownloadButton)?.isEnabled = false
|
||||
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.isEnabled = false
|
||||
}
|
||||
|
||||
view?.findViewById<TextView>(R.id.singleAppContentId)?.setOnLongClickListener {
|
||||
val clipboardManager = requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clipData = ClipData.newPlainText(
|
||||
"Content ID",
|
||||
view.findViewById<TextView>(R.id.singleAppContentId)?.text
|
||||
)
|
||||
clipboardManager.setPrimaryClip(clipData)
|
||||
Toast.makeText(requireContext(), getString(R.string.content_id_copied), Toast.LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
|
||||
// click listener for the download button
|
||||
view?.findViewById<Button>(R.id.singleAppDownloadButton)?.setOnClickListener {
|
||||
try {
|
||||
val request = DownloadManager.Request(Uri.parse(app.link))
|
||||
request.setTitle(app.title)
|
||||
request.setDescription(getString(R.string.downloading))
|
||||
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
request.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
app.title!!.replace("[\\\\/:*?\"<>|]".toRegex(), " ") + ".pkg"
|
||||
) // TODO: set up preference for this
|
||||
downloadManager.enqueue(request)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
getString(R.string.download_error),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
// long click listener for download button
|
||||
view?.findViewById<Button>(R.id.singleAppDownloadButton)?.setOnLongClickListener {
|
||||
val clipboardManager = requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clipData = ClipData.newPlainText("PKG URL", app.link)
|
||||
clipboardManager.setPrimaryClip(clipData)
|
||||
Toast.makeText(requireContext(), getString(R.string.pkg_url_copied), Toast.LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
|
||||
// click listener for the copyzrifbutton button
|
||||
view?.findViewById<Button>(R.id.singleAppCopyZRIFButton)?.setOnClickListener {
|
||||
val clipboardManager = requireActivity().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clipData = ClipData.newPlainText("zRIF", app.license)
|
||||
clipboardManager.setPrimaryClip(clipData)
|
||||
Toast.makeText(requireContext(),getString(R.string.zrif_copied),Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
// set the height for the error text to 0 if there's no error so that the scrollview can take more space
|
||||
if (view?.findViewById<TextView>(R.id.singleAppError)?.text.isNullOrEmpty()) {
|
||||
view?.findViewById<TextView>(R.id.singleAppError)?.layoutParams?.height = 0
|
||||
}
|
||||
|
||||
if (app.contentID != null) {
|
||||
val circularProgressDrawable = CircularProgressDrawable(requireContext())
|
||||
circularProgressDrawable.setColorSchemeColors(0x43EB92)
|
||||
circularProgressDrawable.strokeWidth = 10f
|
||||
circularProgressDrawable.centerRadius = 180f
|
||||
circularProgressDrawable.start()
|
||||
|
||||
val image : ImageView? = view?.findViewById(R.id.singleAppIcon)
|
||||
val iconURL = getImage(app.contentID!!)
|
||||
if (iconURL != null) {
|
||||
// TODO: make the options a requestOptions
|
||||
Glide.with(this).load(iconURL).placeholder(circularProgressDrawable).error(R.drawable.ic_games_placeholder_24dp).into(image!!)
|
||||
}
|
||||
}
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
private fun prettifySize(): String {
|
||||
if (app.fileSize!!.toFloat() / (1024 * 1024 * 1024) > 1) {
|
||||
return "%.1f GB".format(toGB(app.fileSize!!))
|
||||
}
|
||||
if (app.fileSize!!.toFloat() / (1024 * 1024) > 1) {
|
||||
return "%.1f MB".format(toMB(app.fileSize!!))
|
||||
}
|
||||
if (app.fileSize!!.toFloat() / 1024 > 1) {
|
||||
return "%.1f KB".format(toKB(app.fileSize!!))
|
||||
}
|
||||
return "${app.fileSize!!} Bytes"
|
||||
}
|
||||
|
||||
private fun toKB(size: Number): Number {
|
||||
return size.toFloat() / 1024
|
||||
}
|
||||
|
||||
private fun toMB(size: Number): Number {
|
||||
return size.toFloat() / (1024 * 1024)
|
||||
}
|
||||
|
||||
private fun toGB(size: Number): Number {
|
||||
return size.toFloat() / (1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
private fun getImage(contentID: String): String? {
|
||||
when (contentID.substring(0, 2)) {
|
||||
"EP" -> {
|
||||
// european games
|
||||
return "$baseStoreURL/SA/en/999/${contentID}/image"
|
||||
}
|
||||
"UP" -> {
|
||||
// united states games
|
||||
return "$baseStoreURL/US/en/999/${contentID}/image"
|
||||
}
|
||||
"JP" -> {
|
||||
// japanese games
|
||||
return "$baseStoreURL/jp/ja/999/${contentID}/image"
|
||||
}
|
||||
"KP" -> {
|
||||
// korean games
|
||||
return "$baseStoreURL/kr/ko/999/${contentID}/image"
|
||||
}
|
||||
"HP" -> {
|
||||
// asia games
|
||||
return "$baseStoreURL/HK/zh/999/${contentID}/image"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
-283
@@ -1,283 +0,0 @@
|
||||
package com.illusionware.npsbrowser.fragments.mainactivity
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.github.doyaaaaaken.kotlincsv.dsl.csvReader
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.illusionware.npsbrowser.AppData
|
||||
import com.illusionware.npsbrowser.R
|
||||
import com.illusionware.npsbrowser.RecyclerTouchListener
|
||||
import com.illusionware.npsbrowser.fragments.SingleAppFragment
|
||||
import com.illusionware.npsbrowser.adapters.AppAdapter
|
||||
import com.illusionware.npsbrowser.fragments.SettingsFragment
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
class MainActivityFragment : Fragment() {
|
||||
|
||||
var recyclerView : RecyclerView? = null
|
||||
var viewAdapter: AppAdapter? = null
|
||||
var filteredModelList: ArrayList<AppData>? = null
|
||||
|
||||
companion object {
|
||||
var apps : ArrayList<AppData>? = arrayListOf()
|
||||
}
|
||||
|
||||
private val tsvReader = csvReader {
|
||||
charset = "UTF-8"
|
||||
quoteChar = '\\'
|
||||
escapeChar = '\\'
|
||||
delimiter = '\t'
|
||||
skipEmptyLine = true
|
||||
skipMissMatchedRow = true
|
||||
}
|
||||
|
||||
private lateinit var viewModel: MainActivityViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
setHasOptionsMenu(true)
|
||||
activity?.title = resources.getString(R.string.app_name)
|
||||
|
||||
val view = inflater.inflate(R.layout.main_fragment, container, false)
|
||||
|
||||
view?.findViewById<FloatingActionButton>(R.id.tsvChooserButton)?.setOnClickListener {
|
||||
if (!isStoragePermissionGranted())
|
||||
return@setOnClickListener
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "text/tab-separated-values"
|
||||
}
|
||||
startActivityForResult(intent, 1)
|
||||
}
|
||||
viewModel = ViewModelProviders.of(this).get(MainActivityViewModel::class.java)
|
||||
// TODO: Use the ViewModel
|
||||
|
||||
recyclerView = view?.findViewById(R.id.appsRecycler)
|
||||
|
||||
if (viewAdapter == null) {
|
||||
viewAdapter = AppAdapter(requireContext())
|
||||
viewAdapter?.edit()?.add(apps!!)?.commit()
|
||||
}
|
||||
|
||||
if (apps != null) {
|
||||
filteredModelList = apps
|
||||
}
|
||||
viewAdapter?.notifyDataSetChanged()
|
||||
|
||||
recyclerView?.adapter = viewAdapter
|
||||
|
||||
val orientation = resources.configuration.orientation
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val isViewAsList = prefs.getString("layout_type", "0")
|
||||
if (isViewAsList == "0") {
|
||||
viewAdapter?.toggleItemViewType(0)
|
||||
recyclerView?.layoutManager = LinearLayoutManager(context)
|
||||
viewAdapter?.notifyDataSetChanged()
|
||||
} else {
|
||||
viewAdapter?.toggleItemViewType(1)
|
||||
recyclerView?.layoutManager = GridLayoutManager(
|
||||
context,
|
||||
if (orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
|
||||
)
|
||||
viewAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
recyclerView?.addOnItemTouchListener(RecyclerTouchListener(
|
||||
requireContext(),
|
||||
recyclerView!!,
|
||||
object : RecyclerTouchListener.ClickListener {
|
||||
override fun onClick(view: View?, position: Int) {
|
||||
SingleAppFragment.app = filteredModelList!![position]
|
||||
activity?.supportFragmentManager
|
||||
?.beginTransaction()
|
||||
?.replace(R.id.container,
|
||||
SingleAppFragment()
|
||||
)
|
||||
?.addToBackStack("single app")
|
||||
?.commit()
|
||||
}
|
||||
|
||||
override fun onLongClick(
|
||||
view: View?,
|
||||
recyclerView: RecyclerView?,
|
||||
position: Int
|
||||
) {
|
||||
Toast.makeText(context, "TODO: Long Click $position", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
))
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, resultData)
|
||||
if (requestCode == 1 && resultCode == Activity.RESULT_OK) {
|
||||
resultData?.data?.also { uri ->
|
||||
val inputStream: InputStream? = activity?.contentResolver?.openInputStream(uri)
|
||||
val entries : List<Map<String, String?>> = tsvReader.readAllWithHeader(inputStream!!)
|
||||
apps?.clear()
|
||||
apps = ArrayList(entries.size)
|
||||
|
||||
entries.forEach { entry ->
|
||||
if (entry["Name"].isNullOrEmpty()) {
|
||||
return@forEach
|
||||
}
|
||||
val id = entry["Title ID"]
|
||||
val region = entry["Region"]
|
||||
val name = entry["Name"]
|
||||
val link = entry["PKG direct link"]
|
||||
val license = entry["zRIF"]
|
||||
val contentID = entry["Content ID"]
|
||||
var lastDateTime = entry["Last Modification Date"]
|
||||
if (!lastDateTime.isNullOrEmpty()) {
|
||||
lastDateTime = lastDateTime.substring(0, 10)
|
||||
}
|
||||
val fileSize = entry["File Size"]?.toLongOrNull()
|
||||
val sha256 = entry["SHA256"]
|
||||
val minFW = entry["Required FW"]
|
||||
val app = AppData(
|
||||
id, region, name, link ?: "MISSING", license ?: "MISSING",
|
||||
contentID ?: "MISSING", lastDateTime ?: "MISSING",
|
||||
fileSize, sha256 ?: "MISSING", minFW
|
||||
)
|
||||
apps?.add(app)
|
||||
}
|
||||
apps?.sortBy { it.title }
|
||||
filteredModelList = apps
|
||||
|
||||
recyclerView = view?.findViewById(R.id.appsRecycler)
|
||||
viewAdapter = AppAdapter(requireContext())
|
||||
viewAdapter?.edit()?.add(apps!!)?.commit()
|
||||
val orientation = this.resources.configuration.orientation
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val isViewAsList = prefs.getString("layout_type", "0")
|
||||
|
||||
if (isViewAsList == "0") {
|
||||
viewAdapter?.toggleItemViewType(0)
|
||||
recyclerView?.layoutManager = LinearLayoutManager(context)
|
||||
viewAdapter?.notifyDataSetChanged()
|
||||
} else {
|
||||
viewAdapter?.toggleItemViewType(1)
|
||||
recyclerView?.layoutManager = GridLayoutManager(
|
||||
context,
|
||||
if (orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
|
||||
)
|
||||
viewAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
recyclerView?.adapter = viewAdapter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.main, menu)
|
||||
|
||||
val searchItem = menu.findItem(R.id.action_search)
|
||||
val searchView: SearchView = searchItem.actionView as SearchView
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(query: String): Boolean {
|
||||
filteredModelList = filter(apps!!, query) as ArrayList<AppData>?
|
||||
viewAdapter?.edit()?.replaceAll(filteredModelList!!)?.commit()
|
||||
recyclerView?.scrollToPosition(0)
|
||||
return false
|
||||
}
|
||||
})
|
||||
super.onCreateOptionsMenu(menu, menuInflater)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_settings -> {
|
||||
activity?.supportFragmentManager
|
||||
?.beginTransaction()
|
||||
?.replace(
|
||||
R.id.container,
|
||||
SettingsFragment()
|
||||
)
|
||||
?.addToBackStack("settings")
|
||||
?.commit()
|
||||
true
|
||||
}
|
||||
R.id.action_search -> {
|
||||
true
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
viewAdapter?.edit()?.replaceAll(apps!!)?.commit()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
val orientation = newConfig.orientation
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
val isViewAsList = prefs.getString("layout_type", "0")
|
||||
if (isViewAsList == "0") {
|
||||
val pos = (recyclerView?.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
|
||||
viewAdapter?.toggleItemViewType(0)
|
||||
recyclerView?.layoutManager = LinearLayoutManager(context)
|
||||
(recyclerView?.layoutManager as LinearLayoutManager).scrollToPosition(pos)
|
||||
viewAdapter?.notifyDataSetChanged()
|
||||
} else {
|
||||
val pos = (recyclerView?.layoutManager as GridLayoutManager).findFirstVisibleItemPosition()
|
||||
viewAdapter?.toggleItemViewType(1)
|
||||
recyclerView?.layoutManager = GridLayoutManager(
|
||||
context,
|
||||
if (orientation == Configuration.ORIENTATION_LANDSCAPE) 4 else 2
|
||||
)
|
||||
(recyclerView?.layoutManager as GridLayoutManager).scrollToPosition(pos)
|
||||
viewAdapter?.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isStoragePermissionGranted(): Boolean {
|
||||
return if (checkSelfPermission(
|
||||
this.requireContext(),
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED) {
|
||||
true
|
||||
} else {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun filter(models: List<AppData>, query: String): List<AppData>? {
|
||||
val lowerCaseQuery = query.lowercase(Locale.getDefault())
|
||||
val filteredModelList: MutableList<AppData> = ArrayList()
|
||||
for (model in models) {
|
||||
val title: String = model.title?.lowercase(Locale.getDefault())!!
|
||||
val id: String = model.titleID?.lowercase(Locale.getDefault())!!
|
||||
if (title.contains(lowerCaseQuery) || id.contains(lowerCaseQuery)) {
|
||||
filteredModelList.add(model)
|
||||
}
|
||||
}
|
||||
return filteredModelList
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package com.illusionware.npsbrowser.fragments.mainactivity
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class MainActivityViewModel : ViewModel() {
|
||||
// TODO: Implement the ViewModel
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.illusionware.npsbrowser.model
|
||||
|
||||
enum class ConsoleType {
|
||||
PSVITA,
|
||||
PS3,
|
||||
PSP,
|
||||
PSX,
|
||||
PSM,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 pkgSizeInBytes: ULong?,
|
||||
val sha256: String?,
|
||||
val zRif: String?,
|
||||
val rap: String?,
|
||||
val minFW: String?,
|
||||
|
||||
val consoleType: ConsoleType,
|
||||
val dataType: PackageItemType,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.illusionware.npsbrowser.model
|
||||
|
||||
enum class PackageItemType {
|
||||
GAME,
|
||||
DLC,
|
||||
THEME,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.illusionware.npsbrowser.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.illusionware.npsbrowser.ui.theme.Typography
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NPSAlertDialog(
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
buttons: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Surface (
|
||||
modifier = Modifier.wrapContentHeight().wrapContentWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = AlertDialogDefaults.TonalElevation
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = title, style = Typography.titleLarge)
|
||||
content()
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -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,77 @@
|
||||
package com.illusionware.npsbrowser.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NPSIconButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
badge: (@Composable BoxScope.() -> Unit)? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(
|
||||
content = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
Text(text = tooltip)
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
Box(Modifier.layoutId("badge")) {
|
||||
badge?.let { it() }
|
||||
}
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val iconPlaceable = measurables[0].measure(constraints)
|
||||
val badgePlaceable = measurables.firstOrNull { it.layoutId == "badge" }?.measure(constraints)
|
||||
|
||||
layout(iconPlaceable.width, iconPlaceable.height) {
|
||||
iconPlaceable.placeRelative(0, 0)
|
||||
badgePlaceable?.placeRelative(
|
||||
x = iconPlaceable.width - badgePlaceable.width - 16,
|
||||
y = 16,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PlainTooltipBox(tooltip = {
|
||||
// Text(text = tooltip)
|
||||
// }) {
|
||||
// if (badge != null) {
|
||||
// IconButton(
|
||||
// onClick = onClick,
|
||||
// Modifier.tooltipAnchor(),
|
||||
// ) {
|
||||
// BadgedBox(badge = badge) {
|
||||
// content()
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// IconButton(
|
||||
// onClick = onClick,
|
||||
// Modifier.tooltipAnchor(),
|
||||
// ) {
|
||||
// content()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.illusionware.npsbrowser.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun NPSRadioButton(text: String = "Radio Button", onClick: () -> Unit = {}, selected: Boolean = true) {
|
||||
Box (Modifier.clickable { onClick() }.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.illusionware.npsbrowser.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.RestartAlt
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.illusionware.npsbrowser.data.download.NPSPackageDownload
|
||||
import com.illusionware.npsbrowser.ui.components.NPSIconButton
|
||||
import com.illusionware.npsbrowser.ui.theme.Typography
|
||||
import com.illusionware.npsbrowser.util.system.prettyByte
|
||||
import com.illusionware.npsbrowser.viewmodels.PackageDetailsViewModel
|
||||
|
||||
@Composable
|
||||
fun Downloads(navigationGoBack: () -> Unit, packageDetailsViewModel: PackageDetailsViewModel) {
|
||||
val queue = packageDetailsViewModel.downloadManager.queueState.collectAsStateWithLifecycle().value
|
||||
packageDetailsViewModel.downloadManager.statusFlow().collectAsStateWithLifecycle(initialValue = null)
|
||||
val hasActiveDownloads = queue.any {
|
||||
it.status == NPSPackageDownload.State.DOWNLOADING ||
|
||||
it.status == NPSPackageDownload.State.QUEUE
|
||||
}
|
||||
val hasResumableDownloads = queue.any {
|
||||
it.status == NPSPackageDownload.State.PAUSED ||
|
||||
it.status == NPSPackageDownload.State.ERROR
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" )
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(text = "Downloads", style = Typography.titleLarge)
|
||||
if (queue.isNotEmpty()) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.outlineVariant) {
|
||||
Text(
|
||||
text = queue.size.toString(),
|
||||
style = Typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (hasActiveDownloads) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(text = "Pause All") },
|
||||
icon = { Icon(imageVector = Icons.Filled.Pause, contentDescription = "Pause")},
|
||||
onClick = {
|
||||
packageDetailsViewModel.downloadManager.pauseDownloads()
|
||||
}
|
||||
)
|
||||
} else if (hasResumableDownloads) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(text = "Resume") },
|
||||
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = "Resume")},
|
||||
onClick = {
|
||||
packageDetailsViewModel.downloadManager.startDownloads()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
LazyColumn(Modifier.background(MaterialTheme.colorScheme.surface)) {
|
||||
itemsIndexed(
|
||||
queue,
|
||||
key = { _, item -> item.url },
|
||||
) { _, item ->
|
||||
DownloadItem(
|
||||
item,
|
||||
packageDetailsViewModel::removeDownload,
|
||||
packageDetailsViewModel::pauseDownload,
|
||||
packageDetailsViewModel::resumeDownload
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadItem(
|
||||
item: NPSPackageDownload,
|
||||
removeDownload: (String) -> Unit,
|
||||
pauseDownload: (String) -> Unit,
|
||||
resumeDownload: (String) -> Unit,
|
||||
) {
|
||||
val progress = item.progressFlow.collectAsStateWithLifecycle().value
|
||||
val bytesDownloaded = item.bytesDownloadedFlow.collectAsStateWithLifecycle().value
|
||||
val status = item.statusFlow.collectAsStateWithLifecycle().value
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
if (status == NPSPackageDownload.State.DOWNLOADING || status == NPSPackageDownload.State.QUEUE) {
|
||||
pauseDownload(item.url)
|
||||
} else if (status != NPSPackageDownload.State.DOWNLOADED) {
|
||||
resumeDownload(item.url)
|
||||
}
|
||||
}
|
||||
.size(48.dp)
|
||||
.aspectRatio(1f)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (status) {
|
||||
NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> Icons.Filled.Pause
|
||||
NPSPackageDownload.State.DOWNLOADED -> Icons.Filled.DownloadDone
|
||||
NPSPackageDownload.State.ERROR -> Icons.Filled.RestartAlt
|
||||
else -> Icons.Filled.PlayArrow
|
||||
},
|
||||
contentDescription = when (status) {
|
||||
NPSPackageDownload.State.DOWNLOADING, NPSPackageDownload.State.QUEUE -> "Pause"
|
||||
NPSPackageDownload.State.DOWNLOADED -> "Downloaded"
|
||||
NPSPackageDownload.State.ERROR -> "Retry"
|
||||
else -> "Resume"
|
||||
},
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(end = 4.dp)
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = item.name,
|
||||
style = Typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(text = "${bytesDownloaded.prettyByte()} / ${item.size.prettyByte()}", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
when (status) {
|
||||
NPSPackageDownload.State.DOWNLOADING -> {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.height(2.dp),
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
NPSPackageDownload.State.QUEUE -> {
|
||||
Text("Pending", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
NPSPackageDownload.State.ERROR -> {
|
||||
Text("Failed", style = Typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
NPSPackageDownload.State.DOWNLOADED -> {
|
||||
Text("Downloaded", style = Typography.bodySmall, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
else -> {
|
||||
Text("Paused", style = Typography.bodySmall, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(12.dp))
|
||||
.clickable { removeDownload(item.url) }
|
||||
.size(48.dp)
|
||||
.aspectRatio(1f)
|
||||
.semantics {
|
||||
role = Role.Button
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Clear,
|
||||
contentDescription = "Remove",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun DownloadPreview() {
|
||||
val packageDetailsViewModel: PackageDetailsViewModel = viewModel(
|
||||
factory = PackageDetailsViewModel.Factory
|
||||
)
|
||||
|
||||
Downloads({}, packageDetailsViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun DownloadItemPreview() {
|
||||
DownloadItem(
|
||||
item = NPSPackageDownload(
|
||||
name = "Test",
|
||||
url = "https://google.com",
|
||||
size = 385273850L,
|
||||
createdAt = 0L,
|
||||
sha256 = "",
|
||||
titleId = "BRUH00001",
|
||||
bytesDownloaded = 0L
|
||||
),
|
||||
removeDownload = {},
|
||||
pauseDownload = {},
|
||||
resumeDownload = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package com.illusionware.npsbrowser.ui.screens
|
||||
|
||||
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
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.illusionware.npsbrowser.R
|
||||
import com.illusionware.npsbrowser.ui.theme.ColorAccent
|
||||
import com.illusionware.npsbrowser.ui.theme.Typography
|
||||
import com.illusionware.npsbrowser.ui.theme.md_theme_dark_onSurface
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
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.unit.sp
|
||||
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.viewmodels.OnboardingViewModel
|
||||
import com.illusionware.npsbrowser.viewmodels.SettingsViewModel
|
||||
import androidx.core.view.WindowCompat
|
||||
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,
|
||||
onboardingViewModel: OnboardingViewModel = viewModel(
|
||||
factory = OnboardingViewModel.Factory
|
||||
),
|
||||
settingsViewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModel.Factory
|
||||
),
|
||||
) {
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = 0,
|
||||
initialPageOffsetFraction = 0f
|
||||
) {
|
||||
PAGE_COUNT
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val settingsPrefs = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
|
||||
val darkIcons = when (pagerState.currentPage) {
|
||||
0 -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
DisposableEffect(view, darkIcons, darkTheme) {
|
||||
val activity = context as Activity
|
||||
val window = activity.window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
|
||||
window.statusBarColor = Color.Transparent.toArgb()
|
||||
insetsController.isAppearanceLightStatusBars = darkIcons && !darkTheme
|
||||
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
onDispose {
|
||||
insetsController.isAppearanceLightStatusBars = !darkTheme
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(state = pagerState) { page ->
|
||||
when (page) {
|
||||
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) }
|
||||
},
|
||||
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,
|
||||
settingsViewModel,
|
||||
onboardingViewModel,
|
||||
settingsPrefs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun OnBoardingInitial(incrementPage: () -> Unit = {}) {
|
||||
val density = LocalDensity.current
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = visible) {
|
||||
visible = true
|
||||
}
|
||||
|
||||
Scaffold(containerColor = ColorAccent, contentColor = MaterialTheme.colorScheme.onBackground) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.statusBarsPadding(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(56.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically {
|
||||
h -> h
|
||||
} + fadeIn(
|
||||
initialAlpha = 0.2f
|
||||
),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.nps_fg_onboarding),
|
||||
contentDescription = "App Logo",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 64.dp)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(tween(delayMillis = 100)) {
|
||||
with(density) { 50.dp.roundToPx() }
|
||||
} + fadeIn(
|
||||
animationSpec = tween(delayMillis = 50),
|
||||
initialAlpha = 0.0f,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(48.dp)
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ProvideTextStyle(value = Typography.displayMedium.copy(color = Color.White)) {
|
||||
Text(text = "No Ads.")
|
||||
Text(text = "No Waiting.")
|
||||
Text(text = "No Bullshit.")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Browse and download from a huge library of games, themes, and DLC to your heart's content.",
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
style = Typography.titleLarge.copy(color = md_theme_dark_onSurface),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(
|
||||
animationSpec = tween(delayMillis = 200, durationMillis = 500),
|
||||
initialAlpha = 0.0f,
|
||||
),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = incrementPage,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color.White, containerColor = Color(0xFF1A91EC)),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp)
|
||||
) {
|
||||
Text(text = "Get Started")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnBoardingSetupTSVs(
|
||||
page: Int = 1,
|
||||
incrementPage: () -> Unit = {},
|
||||
skipToEnd: () -> Unit = {},
|
||||
settingsViewModel: SettingsViewModel,
|
||||
) {
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.padding(vertical = 32.dp)
|
||||
.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = "Set up TSVs", style = Typography.displayMedium)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "You can set up your TSV files on this screen",
|
||||
style = Typography.titleMedium.copy(color = MaterialTheme.colorScheme.outline),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = "You are expected to provide the TSV files yourself. NPS Browser will NOT provide you with these files",
|
||||
style = Typography.titleSmall.copy(color = Color(0xFFD63A3A)),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1.0f, fill = false)
|
||||
.padding(vertical = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TSVGroup(title = "PS Vita") {
|
||||
TSVButton(
|
||||
"Games",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it) }
|
||||
)
|
||||
TSVButton(
|
||||
"Themes",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it) }
|
||||
)
|
||||
TSVButton(
|
||||
"DLC",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it) }
|
||||
)
|
||||
}
|
||||
TSVGroup(title = "PSP") {
|
||||
TSVButton(
|
||||
"Games",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it) }
|
||||
)
|
||||
TSVButton(
|
||||
"DLC",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSP, PackageItemType.DLC, it) }
|
||||
)
|
||||
Box(modifier = Modifier.width(110.dp))
|
||||
}
|
||||
TSVGroup(title = "PS3") {
|
||||
TSVButton(
|
||||
"Games",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it) }
|
||||
)
|
||||
TSVButton(
|
||||
"DLC",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PS3, PackageItemType.DLC, it) }
|
||||
)
|
||||
Box(modifier = Modifier.width(110.dp))
|
||||
}
|
||||
TSVGroup(title = "PSX") {
|
||||
TSVButton(
|
||||
"Games",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it) }
|
||||
)
|
||||
}
|
||||
TSVGroup(title = "PSM") {
|
||||
TSVButton(
|
||||
"Games",
|
||||
onFinish = { settingsViewModel.setTSVFile(ConsoleType.PSM, PackageItemType.GAME, it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
) {
|
||||
OnBoardingBottomButtons(
|
||||
page = page,
|
||||
incrementPage = incrementPage,
|
||||
skipToEnd = skipToEnd,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColumnGroup(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
titleModifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
modifier = titleModifier,
|
||||
text = title,
|
||||
style = TextStyle.Default.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TSVGroup(title: String, content: @Composable RowScope.() -> Unit) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = TextStyle.Default.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TSVButton(title: String, onFinish: (Uri) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var hasSelected by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(
|
||||
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(arrayOf("text/tab-separated-values"))
|
||||
},
|
||||
modifier = Modifier
|
||||
.width(110.dp)
|
||||
.height(160.dp)
|
||||
.padding(vertical = 8.dp, horizontal = 0.dp)
|
||||
.graphicsLayer(clip = false),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
containerColor = if (!hasSelected) MaterialTheme.colorScheme.secondaryContainer else Color(0x335EC03C)
|
||||
),
|
||||
) {
|
||||
Layout(
|
||||
modifier = Modifier.graphicsLayer(clip = false),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.tsv_file),
|
||||
contentDescription = "Add TSV",
|
||||
modifier = Modifier
|
||||
.weight(1.0f)
|
||||
.fillMaxWidth(),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Text(text = title, style = Typography.titleMedium)
|
||||
}
|
||||
Icon(
|
||||
modifier = Modifier.layoutId("selected"),
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = "Selected",
|
||||
tint = Color(0xFF5EC03C)
|
||||
)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val buttonPlaceable = measurables[0].measure(constraints)
|
||||
|
||||
val iconPlaceable = measurables.first { it.layoutId == "selected" }.measure(constraints)
|
||||
|
||||
layout(buttonPlaceable.width, buttonPlaceable.height) {
|
||||
if (hasSelected) {
|
||||
iconPlaceable.placeRelative(
|
||||
x = buttonPlaceable.width - iconPlaceable.width,
|
||||
y = -8,
|
||||
)
|
||||
}
|
||||
buttonPlaceable.placeRelative(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//@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
|
||||
fun OnBoardingSetupSettings(
|
||||
page: Int = 2,
|
||||
incrementPage: () -> Unit = {},
|
||||
decrementPage: () -> Unit = {},
|
||||
skipToEnd: () -> Unit = {},
|
||||
navigateToHome: () -> Unit = {},
|
||||
settingsViewModel: SettingsViewModel,
|
||||
onboardingViewModel: OnboardingViewModel,
|
||||
settingsPrefs: SettingsPreferences,
|
||||
) {
|
||||
val downloadDirPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { dirUri ->
|
||||
if (dirUri != null) {
|
||||
settingsViewModel.setDownloadDir(dirUri.toString())
|
||||
}
|
||||
}
|
||||
|
||||
val unpackDirPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { dirUri ->
|
||||
if (dirUri != null) {
|
||||
settingsViewModel.setUnpackDir(dirUri.toString())
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.padding(vertical = 32.dp)
|
||||
.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = "Configure App", style = Typography.displayMedium)
|
||||
Text(
|
||||
text = "Set the app's settings to your liking and control how it behaves",
|
||||
style = Typography.titleMedium.copy(color = MaterialTheme.colorScheme.outline),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1.0f, fill = false)
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
ColumnGroup(title = "Downloads", titleModifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = "Choose your desired directory to download and unpack packages",
|
||||
style = Typography.bodyMedium.copy(color = MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
Column {
|
||||
Toggleable(
|
||||
title = "Unpack in download directory",
|
||||
checked = settingsPrefs.unpackInDownload,
|
||||
onCheckedChange = {
|
||||
settingsViewModel.setUnpackInDownload(it)
|
||||
},
|
||||
)
|
||||
Toggleable(
|
||||
title = "Delete package after unpacking",
|
||||
checked = settingsPrefs.deleteAfterUnpack,
|
||||
onCheckedChange = {
|
||||
settingsViewModel.setDeleteAfterUnpack(it)
|
||||
},
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { downloadDirPicker.launch(Uri.EMPTY) }
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Download Directory",
|
||||
)
|
||||
Text(
|
||||
text = Uri.decode(settingsPrefs.downloadDir).ifEmpty { "None" },
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { unpackDirPicker.launch(Uri.EMPTY) }
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Unpack Directory",
|
||||
)
|
||||
Text(
|
||||
text = Uri.decode(settingsPrefs.unpackDir).ifEmpty { "None" },
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnGroup(title = "PKG2ZIP", titleModifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = "Modify the parameters and arguments of pkg2zip",
|
||||
style = Typography.bodyMedium.copy(color = MaterialTheme.colorScheme.outline),
|
||||
)
|
||||
Toggleable(
|
||||
title = "Automatically decrypt downloaded content",
|
||||
checked = settingsPrefs.pkg2zipAutoDecrypt,
|
||||
onCheckedChange = {
|
||||
settingsViewModel.setPkg2zipAutoDecrypt(it)
|
||||
},
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
value = settingsPrefs.pkg2zipParams,
|
||||
onValueChange = {
|
||||
settingsViewModel.setPkg2zipParams(it)
|
||||
}, // TODO: validate args are legal ??
|
||||
label = { Text(text = "Pkg2zip args") },
|
||||
placeholder = { Text(text = "-x {pkgFile} \"{zRifKey}\"") },
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
OnBoardingBottomButtons(
|
||||
page = page,
|
||||
incrementPage = incrementPage,
|
||||
decrementPage = decrementPage,
|
||||
skipToEnd = skipToEnd,
|
||||
navigateToHome = navigateToHome,
|
||||
settingsViewModel = settingsViewModel,
|
||||
onboardingViewModel = onboardingViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Toggleable(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable onCheckedChange@{
|
||||
onCheckedChange(!checked)
|
||||
}
|
||||
.padding(vertical = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
thumbContent = if (checked) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OnBoardingBottomButtons(
|
||||
page: Int,
|
||||
incrementPage: () -> Unit = {},
|
||||
decrementPage: () -> Unit = {},
|
||||
skipToEnd: () -> Unit = {},
|
||||
navigateToHome: () -> Unit = {},
|
||||
settingsViewModel: SettingsViewModel? = null,
|
||||
onboardingViewModel: OnboardingViewModel? = null
|
||||
) {
|
||||
val finalPage = PAGE_COUNT - 1
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.1f),
|
||||
horizontalArrangement = if (page != finalPage) Arrangement.SpaceBetween else Arrangement.End,
|
||||
) {
|
||||
if (page != finalPage) {
|
||||
FilledTonalButton(
|
||||
onClick = skipToEnd,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp)
|
||||
) {
|
||||
Text(text = "Skip")
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (page != 1) {
|
||||
Button(onClick = decrementPage, contentPadding = PaddingValues(horizontal = 18.dp)) {
|
||||
Text(text = "Prev")
|
||||
}
|
||||
}
|
||||
if (page == finalPage) {
|
||||
Button(
|
||||
onClick = {
|
||||
navigateToHome()
|
||||
onboardingViewModel?.finishOnboarding(settingsViewModel!!.shouldCreateDefaultDir())
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 18.dp)
|
||||
) {
|
||||
Text(text = "Finish")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = incrementPage,
|
||||
contentPadding = PaddingValues(horizontal = 18.dp)
|
||||
) {
|
||||
Text(text = "Next")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package com.illusionware.npsbrowser.ui.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
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.automirrored.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.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
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.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.PlaceholderColor
|
||||
import com.illusionware.npsbrowser.data.SettingsPreferences
|
||||
import com.illusionware.npsbrowser.data.download.NPSPackageDownload
|
||||
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
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PackageDetails(
|
||||
navigationGoBack: () -> Unit = {},
|
||||
settingsViewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModel.Factory
|
||||
),
|
||||
packageDetailsViewModel: PackageDetailsViewModel,
|
||||
) {
|
||||
val clipboard = LocalClipboard.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) }
|
||||
var pendingOverwriteDownload by remember { mutableStateOf<NPSPackageDownload?>(null) }
|
||||
|
||||
fun buildDownloadRequest(): NPSPackageDownload {
|
||||
return NPSPackageDownload(
|
||||
item.pkgUrl!!,
|
||||
item.titleId,
|
||||
item.name,
|
||||
item.sha256,
|
||||
item.pkgSizeInBytes?.toLong() ?: 0,
|
||||
0L,
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
fun requestDownload(overwriteExisting: Boolean = false) {
|
||||
val downloadRequest = buildDownloadRequest()
|
||||
if (!overwriteExisting && packageDetailsViewModel.downloadManager.shouldConfirmOverwrite(downloadRequest.url)) {
|
||||
pendingOverwriteDownload = downloadRequest
|
||||
return
|
||||
}
|
||||
|
||||
packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = overwriteExisting)
|
||||
}
|
||||
|
||||
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.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" )
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NPSFloatingActionButton(
|
||||
onClick = {
|
||||
if (!item.zRif.isNullOrEmpty()) {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.zRif, item.zRif)))
|
||||
}
|
||||
} else if (!item.rap.isNullOrEmpty()) {
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(item.rap, 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 = {
|
||||
requestDownload()
|
||||
},
|
||||
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,
|
||||
packageDetailsViewModel = packageDetailsViewModel,
|
||||
onDownload = { requestDownload() }
|
||||
)
|
||||
}
|
||||
|
||||
pendingOverwriteDownload?.let { downloadRequest ->
|
||||
NPSAlertDialog(
|
||||
onDismiss = { pendingOverwriteDownload = null },
|
||||
title = "Overwrite existing package?",
|
||||
buttons = {
|
||||
TextButton(onClick = { pendingOverwriteDownload = null }) {
|
||||
Text(text = "Keep Existing")
|
||||
}
|
||||
TextButton(onClick = {
|
||||
pendingOverwriteDownload = null
|
||||
packageDetailsViewModel.downloadManager.download(downloadRequest, overwriteExisting = true)
|
||||
}) {
|
||||
Text(text = "Overwrite")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("This package is already downloaded. Choose whether to keep the existing file or overwrite it.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OneTimeConfigBottomSheet(
|
||||
sheetState: SheetState,
|
||||
onDismiss: () -> Unit,
|
||||
prefs: SettingsPreferences,
|
||||
packageDetailsViewModel: PackageDetailsViewModel,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
//windowInsets = WindowInsets(top = 0.dp),
|
||||
) {
|
||||
OneTimeConfigSheetContent(prefs, packageDetailsViewModel, onDownload)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OneTimeConfigSheetContent(
|
||||
prefs: SettingsPreferences,
|
||||
packageDetailsViewModel: PackageDetailsViewModel,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
val item = packageDetailsViewModel.uiState.collectAsStateWithLifecycle().value.item!!
|
||||
|
||||
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 = {
|
||||
onDownload()
|
||||
},
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
package com.illusionware.npsbrowser.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.FileDownload
|
||||
import androidx.compose.material.icons.outlined.Palette
|
||||
import androidx.compose.material.icons.outlined.Unarchive
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 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 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
|
||||
|
||||
// TODO: localize this
|
||||
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 = {},
|
||||
viewModel: SettingsViewModel = viewModel(
|
||||
factory = SettingsViewModel.Factory
|
||||
)
|
||||
) {
|
||||
val prefs = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
|
||||
Scaffold (
|
||||
topBar = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
) {
|
||||
NPSIconButton(tooltip = "Go Back", onClick = { navigationGoBack() }) {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Go Back" )
|
||||
}
|
||||
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)
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Appearance(viewModel, prefs)
|
||||
TsvFiles(viewModel, prefs)
|
||||
Downloads(viewModel, prefs)
|
||||
Pkg2Zip(viewModel, prefs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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[prefs.appTheme] ?: "None",
|
||||
icon = Icons.Outlined.Palette,
|
||||
onClick = { themeOpenDialog = true }
|
||||
)
|
||||
}
|
||||
if (themeOpenDialog) {
|
||||
ThemeDialog(
|
||||
prefs = prefs,
|
||||
onDismiss = { themeOpenDialog = false },
|
||||
onThemeChange = {
|
||||
viewModel.setTheme(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TsvFiles(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
SettingGroup(title = "TSV Files") {
|
||||
TSVPicker(
|
||||
title = "PSV Games",
|
||||
value = Uri.decode(prefs.psvGames.ifEmpty { "Browse" }),
|
||||
icon = painterResource(id = R.drawable.vita_icon),
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.GAME, it)
|
||||
}
|
||||
)
|
||||
TSVPicker(
|
||||
title = "PSV DLC",
|
||||
value = Uri.decode(prefs.psvDlc.ifEmpty { "Browse" }),
|
||||
icon = null,
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.DLC, it)
|
||||
}
|
||||
)
|
||||
TSVPicker(
|
||||
title = "PSV Themes",
|
||||
value = Uri.decode(prefs.psvThemes.ifEmpty { "Browse" }),
|
||||
icon = null,
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PSVITA, PackageItemType.THEME, it)
|
||||
},
|
||||
)
|
||||
TSVPicker(
|
||||
title = "PS3 Games",
|
||||
value = Uri.decode(prefs.ps3Games).ifEmpty { "Browse" },
|
||||
icon = painterResource(id = R.drawable.ps3_icon),
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.GAME, it)
|
||||
}
|
||||
)
|
||||
if (!expanded) {
|
||||
DialogSetting(
|
||||
title = "Expand",
|
||||
value = "PS3 DLC, PSP Games, PSP DLC, PSX Games, PSM Games",
|
||||
icon = Icons.Outlined.ExpandMore,
|
||||
singleLine = true,
|
||||
onClick = { expanded = true },
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TSVPicker(
|
||||
title = "PS3 DLC",
|
||||
value = Uri.decode(prefs.ps3Dlc).ifEmpty { "Browse" },
|
||||
icon = null,
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PS3, PackageItemType.DLC, it)
|
||||
}
|
||||
)
|
||||
TSVPicker(
|
||||
title = "PSP Games",
|
||||
value = Uri.decode(prefs.pspGames).ifEmpty { "Browse" },
|
||||
icon = painterResource(id = R.drawable.psp_icon),
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.GAME, it)
|
||||
}
|
||||
)
|
||||
TSVPicker(
|
||||
title = "PSP DLC",
|
||||
value = Uri.decode(prefs.pspDlc).ifEmpty { "Browse" },
|
||||
icon = null,
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PSP, PackageItemType.DLC, it)
|
||||
}
|
||||
)
|
||||
TSVPicker(
|
||||
title = "PSX Games",
|
||||
value = Uri.decode(prefs.psxGames).ifEmpty { "Browse" },
|
||||
icon = painterResource(id = R.drawable.psx_icon),
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PSX, PackageItemType.GAME, it)
|
||||
}
|
||||
)
|
||||
TSVPicker(
|
||||
title = "PSM Games",
|
||||
value = Uri.decode(prefs.psmGames).ifEmpty { "Browse" },
|
||||
icon = painterResource(id = R.drawable.psm_icon),
|
||||
onFilePick = {
|
||||
viewModel.setTSVFile(ConsoleType.PSM, PackageItemType.GAME, it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TSVPicker(
|
||||
title: String,
|
||||
value: String,
|
||||
icon: Painter? = null,
|
||||
onFilePick: (Uri) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val filePicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { fileUri ->
|
||||
if (fileUri != null) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
fileUri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
onFilePick(fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
DialogSetting(
|
||||
title = title,
|
||||
value = value,
|
||||
icon = icon,
|
||||
onClick = {
|
||||
filePicker.launch(arrayOf("text/tab-separated-values"))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Downloads(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
|
||||
val downloadDirPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { dirUri ->
|
||||
if (dirUri != null) {
|
||||
viewModel.setDownloadDir(dirUri.toString())
|
||||
}
|
||||
}
|
||||
|
||||
val unpackDirPicker = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { dirUri ->
|
||||
if (dirUri != null) {
|
||||
viewModel.setUnpackDir(dirUri.toString())
|
||||
}
|
||||
}
|
||||
|
||||
SettingGroup(title = "Downloads") {
|
||||
ToggleSetting(
|
||||
title = "Unpack in Download Directory",
|
||||
checked = prefs.unpackInDownload,
|
||||
onClick = {
|
||||
viewModel.setUnpackInDownload(it)
|
||||
}
|
||||
)
|
||||
ToggleSetting(
|
||||
title = "Delete package after unpacking",
|
||||
checked = prefs.deleteAfterUnpack,
|
||||
onClick = {
|
||||
viewModel.setDeleteAfterUnpack(it)
|
||||
}
|
||||
)
|
||||
DialogSetting(
|
||||
title = "Download Directory",
|
||||
value = Uri.decode(prefs.downloadDir).ifEmpty { "None" },
|
||||
icon = Icons.Outlined.FileDownload,
|
||||
onClick = {
|
||||
downloadDirPicker.launch(Uri.EMPTY)
|
||||
}
|
||||
)
|
||||
DialogSetting(
|
||||
title = "Unpack Directory",
|
||||
value = Uri.decode(prefs.unpackDir).ifEmpty { "None" },
|
||||
icon = Icons.Outlined.Unarchive,
|
||||
enabled = !prefs.unpackInDownload,
|
||||
onClick = {
|
||||
unpackDirPicker.launch(Uri.EMPTY)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Pkg2Zip(viewModel: SettingsViewModel, prefs: SettingsPreferences) {
|
||||
var pkg2zipOpenDialog by remember { mutableStateOf(false) }
|
||||
var dialogPkg2zipParams by remember { mutableStateOf("") }
|
||||
|
||||
SideEffect {
|
||||
dialogPkg2zipParams = prefs.pkg2zipParams
|
||||
}
|
||||
|
||||
SettingGroup(title = "Pkg2Zip") {
|
||||
ToggleSetting(
|
||||
title = "Automatically decrypt downloaded content",
|
||||
checked = prefs.pkg2zipAutoDecrypt,
|
||||
onClick = {
|
||||
viewModel.setPkg2zipAutoDecrypt(it)
|
||||
}
|
||||
)
|
||||
DialogSetting(
|
||||
title = "Decryption params",
|
||||
value = prefs.pkg2zipParams.ifEmpty { "None" },
|
||||
icon = painterResource(
|
||||
id = R.drawable.lock_open
|
||||
),
|
||||
onClick = { pkg2zipOpenDialog = true }
|
||||
)
|
||||
}
|
||||
if (pkg2zipOpenDialog) {
|
||||
NPSAlertDialog(
|
||||
onDismiss = { pkg2zipOpenDialog = false },
|
||||
title = "PKG2ZIP Args",
|
||||
buttons = {
|
||||
TextButton(onClick = { pkg2zipOpenDialog = false }) {
|
||||
Text(text = "Cancel")
|
||||
}
|
||||
TextButton(onClick = {
|
||||
viewModel.setPkg2zipParams(dialogPkg2zipParams)
|
||||
pkg2zipOpenDialog = false
|
||||
}) {
|
||||
Text(text = "OK")
|
||||
}
|
||||
}
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = dialogPkg2zipParams,
|
||||
onValueChange = { dialogPkg2zipParams = it },
|
||||
label = { Text(text = "PKG2ZIP Args") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeDialog(
|
||||
prefs: SettingsPreferences,
|
||||
onDismiss: () -> Unit,
|
||||
onThemeChange: (v: Theme) -> Unit
|
||||
) {
|
||||
NPSAlertDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = stringResource(id = R.string.theme),
|
||||
buttons = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = "Cancel")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
NPSRadioButton(
|
||||
text = "Light",
|
||||
onClick = {
|
||||
onThemeChange(Theme.LIGHT)
|
||||
},
|
||||
selected = prefs.appTheme == Theme.LIGHT.ordinal,
|
||||
)
|
||||
NPSRadioButton(
|
||||
text = "Dark",
|
||||
onClick = {
|
||||
onThemeChange(Theme.DARK)
|
||||
},
|
||||
selected = prefs.appTheme == Theme.DARK.ordinal,
|
||||
)
|
||||
NPSRadioButton(
|
||||
text = "Use System Theme",
|
||||
onClick = {
|
||||
onThemeChange(Theme.SYSTEM)
|
||||
},
|
||||
selected = prefs.appTheme == Theme.SYSTEM.ordinal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingGroup(title: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Header(title)
|
||||
Spacer(modifier = Modifier.size(2.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Header(title: String) {
|
||||
Row(Modifier.padding(start = 56.dp)) {
|
||||
Text(text = title, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogSetting(title: String, value: String, icon: Painter?, onClick: () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(painter = icon, contentDescription = null, modifier = Modifier.width(56.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(text = value, color = ColorSecondaryLight, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogSetting(
|
||||
title: String,
|
||||
value: String,
|
||||
icon: ImageVector?,
|
||||
onClick: () -> Unit,
|
||||
singleLine : Boolean = false,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(if (enabled) 1f else 0.5f)
|
||||
.then(if (enabled) Modifier.clickable { onClick() } else Modifier)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.width(56.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleSetting(title: String, icon: ImageVector? = null, checked: Boolean, onClick: (Boolean) -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier.clickable onClick@{
|
||||
onClick(!checked)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.padding(top = 8.dp, end = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, Modifier.width(56.dp))
|
||||
} else {
|
||||
Box(modifier = Modifier.width(56.dp))
|
||||
}
|
||||
Text(text = title, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1.0f))
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
thumbContent = if (checked) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.illusionware.npsbrowser.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val ColorPrimaryLight = Color(0xFFFFFFFF)
|
||||
val ColorPrimaryVariantLight = Color(0xFFEFEFEF)
|
||||
val ColorOnPrimaryLight = Color(0xFF000000)
|
||||
val ColorSecondaryLight = Color(0xFF999999)
|
||||
val ColorSecondaryVariantLight = Color(0xFF888888)
|
||||
val ColorOnSecondaryLight = Color(0xFF111111)
|
||||
val ColorAmoledLight = Color(0xFFFFFFFF)
|
||||
|
||||
val ColorPrimaryDark = Color(0xFF222222)
|
||||
val ColorPrimaryVariantDark = Color(0xFF202020)
|
||||
val ColorOnPrimaryDark = Color(0xFFFFFFFF)
|
||||
val ColorSecondaryDark = Color(0xFF696969)
|
||||
val ColorSecondaryVariantDark = Color(0xFF515151)
|
||||
val ColorOnSecondaryDark = Color(0xDFFFFFFF)
|
||||
val ColorAmoledDark = Color(0xFF000000)
|
||||
|
||||
// Works on both Dark and Light
|
||||
val ColorTag = Color(0xFFEAF654)
|
||||
val ColorAccent = Color(0xFF545FBF)
|
||||
val ColorError = Color(0xFFF93422)
|
||||
|
||||
// Light Mode
|
||||
val md_theme_light_primary = Color(0xFF5951B3)
|
||||
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_primaryContainer = Color(0xFFE4DFFF)
|
||||
val md_theme_light_onPrimaryContainer = Color(0xFF140067)
|
||||
val md_theme_light_secondary = Color(0xFF5E5C71)
|
||||
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_secondaryContainer = Color(0xFFE4DFF9)
|
||||
val md_theme_light_onSecondaryContainer = Color(0xFF1B192C)
|
||||
val md_theme_light_tertiary = Color(0xFF5754A8)
|
||||
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||
val md_theme_light_tertiaryContainer = Color(0xFFE3DFFF)
|
||||
val md_theme_light_onTertiaryContainer = Color(0xFF110563)
|
||||
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||
val md_theme_light_background = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onBackground = Color(0xFF1C1B1F)
|
||||
val md_theme_light_surface = Color(0xFFFFFBFF)
|
||||
val md_theme_light_onSurface = Color(0xFF1C1B1F)
|
||||
val md_theme_light_surfaceVariant = Color(0xFFE5E1EC)
|
||||
val md_theme_light_onSurfaceVariant = Color(0xFF47464F)
|
||||
val md_theme_light_outline = Color(0xFF787680)
|
||||
val md_theme_light_inverseOnSurface = Color(0xFFF4EFF4)
|
||||
val md_theme_light_inverseSurface = Color(0xFF313034)
|
||||
val md_theme_light_inversePrimary = Color(0xFFC5C0FF)
|
||||
val md_theme_light_shadow = Color(0xFF000000)
|
||||
val md_theme_light_surfaceTint = Color(0xFF5951B3)
|
||||
val md_theme_light_outlineVariant = Color(0xFFC8C5D0)
|
||||
val md_theme_light_scrim = Color(0xFF000000)
|
||||
|
||||
// Dark Mode
|
||||
val md_theme_dark_primary = Color(0xFFC5C0FF)
|
||||
val md_theme_dark_onPrimary = Color(0xFF2A1D82)
|
||||
val md_theme_dark_primaryContainer = Color(0xFF413899)
|
||||
val md_theme_dark_onPrimaryContainer = Color(0xFFE4DFFF)
|
||||
val md_theme_dark_secondary = Color(0xFFC8C4DC)
|
||||
val md_theme_dark_onSecondary = Color(0xFF302E41)
|
||||
val md_theme_dark_secondaryContainer = Color(0xFF464559)
|
||||
val md_theme_dark_onSecondaryContainer = Color(0xFFE4DFF9)
|
||||
val md_theme_dark_tertiary = Color(0xFFC3C0FF)
|
||||
val md_theme_dark_onTertiary = Color(0xFF282377)
|
||||
val md_theme_dark_tertiaryContainer = Color(0xFF3F3C8F)
|
||||
val md_theme_dark_onTertiaryContainer = Color(0xFFE3DFFF)
|
||||
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||
val md_theme_dark_onError = Color(0xFF690005)
|
||||
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||
val md_theme_dark_background = Color(0xFF1C1B1F)
|
||||
val md_theme_dark_onBackground = Color(0xFFE5E1E6)
|
||||
val md_theme_dark_surface = Color(0xFF1C1B1F)
|
||||
val md_theme_dark_onSurface = Color(0xFFE5E1E6)
|
||||
val md_theme_dark_surfaceVariant = Color(0xFF47464F)
|
||||
val md_theme_dark_onSurfaceVariant = Color(0xFFC8C5D0)
|
||||
val md_theme_dark_outline = Color(0xFF928F99)
|
||||
val md_theme_dark_inverseOnSurface = Color(0xFF1C1B1F)
|
||||
val md_theme_dark_inverseSurface = Color(0xFFE5E1E6)
|
||||
val md_theme_dark_inversePrimary = Color(0xFF5951B3)
|
||||
val md_theme_dark_shadow = Color(0xFF000000)
|
||||
val md_theme_dark_surfaceTint = Color(0xFFC5C0FF)
|
||||
val md_theme_dark_outlineVariant = Color(0xFF47464F)
|
||||
val md_theme_dark_scrim = Color(0xFF000000)
|
||||
|
||||
|
||||
val seed = Color(0xFF5E56B8)
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.illusionware.npsbrowser.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = md_theme_light_primary,
|
||||
onPrimary = md_theme_light_onPrimary,
|
||||
primaryContainer = md_theme_light_primaryContainer,
|
||||
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||
secondary = md_theme_light_secondary,
|
||||
onSecondary = md_theme_light_onSecondary,
|
||||
secondaryContainer = md_theme_light_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||
tertiary = md_theme_light_tertiary,
|
||||
onTertiary = md_theme_light_onTertiary,
|
||||
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||
error = md_theme_light_error,
|
||||
errorContainer = md_theme_light_errorContainer,
|
||||
onError = md_theme_light_onError,
|
||||
onErrorContainer = md_theme_light_onErrorContainer,
|
||||
background = md_theme_light_background,
|
||||
onBackground = md_theme_light_onBackground,
|
||||
surface = md_theme_light_surface,
|
||||
onSurface = md_theme_light_onSurface,
|
||||
surfaceVariant = md_theme_light_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||
outline = md_theme_light_outline,
|
||||
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||
inverseSurface = md_theme_light_inverseSurface,
|
||||
inversePrimary = md_theme_light_inversePrimary,
|
||||
surfaceTint = md_theme_light_surfaceTint,
|
||||
outlineVariant = md_theme_light_outlineVariant,
|
||||
scrim = md_theme_light_scrim,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = md_theme_dark_primary,
|
||||
onPrimary = md_theme_dark_onPrimary,
|
||||
primaryContainer = md_theme_dark_primaryContainer,
|
||||
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||
secondary = md_theme_dark_secondary,
|
||||
onSecondary = md_theme_dark_onSecondary,
|
||||
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||
tertiary = md_theme_dark_tertiary,
|
||||
onTertiary = md_theme_dark_onTertiary,
|
||||
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||
error = md_theme_dark_error,
|
||||
errorContainer = md_theme_dark_errorContainer,
|
||||
onError = md_theme_dark_onError,
|
||||
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||
background = md_theme_dark_background,
|
||||
onBackground = md_theme_dark_onBackground,
|
||||
surface = md_theme_dark_surface,
|
||||
onSurface = md_theme_dark_onSurface,
|
||||
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||
outline = md_theme_dark_outline,
|
||||
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||
inverseSurface = md_theme_dark_inverseSurface,
|
||||
inversePrimary = md_theme_dark_inversePrimary,
|
||||
surfaceTint = md_theme_dark_surfaceTint,
|
||||
outlineVariant = md_theme_dark_outlineVariant,
|
||||
scrim = md_theme_dark_scrim,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NPSBrowserTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
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()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !darkTheme
|
||||
insetsController.isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.illusionware.npsbrowser.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.os.PowerManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
fun Context.isServiceRunning(serviceClass: Class<*>): Boolean {
|
||||
val className = serviceClass.name
|
||||
val manager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
return manager.getRunningServices(Integer.MAX_VALUE).any { className == it.service.className }
|
||||
}
|
||||
|
||||
val Context.powerManager: PowerManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock {
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock")
|
||||
wakeLock.acquire()
|
||||
return wakeLock
|
||||
}
|
||||
|
||||
fun Context.toast(text: String?, duration: Int = Toast.LENGTH_SHORT, block: (Toast) -> Unit = {}): Toast {
|
||||
return Toast.makeText(applicationContext, text.orEmpty(), duration).also {
|
||||
block(it)
|
||||
it.show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
suspend fun <T> withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block)
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import java.util.Locale.ENGLISH
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
fun Long.prettyByte(): String {
|
||||
val kilobyte: Long = 1024
|
||||
val megabyte = kilobyte * 1024
|
||||
val gigabyte = megabyte * 1024
|
||||
val terabyte = gigabyte * 1024
|
||||
|
||||
return if (this in 0 until kilobyte) {
|
||||
"$this" + "B"
|
||||
} else if (this in kilobyte until megabyte) {
|
||||
String.format(ENGLISH, "%.2f", (this.toDouble() / kilobyte.toDouble())) + "KB"
|
||||
} else if (this in megabyte until gigabyte) {
|
||||
String.format(ENGLISH, "%.2f", (this.toDouble() / megabyte.toDouble())) + "MB"
|
||||
} else if (this in gigabyte until terabyte) {
|
||||
String.format(ENGLISH, "%.2f", (this.toDouble() / gigabyte.toDouble())) + "GB"
|
||||
} else if (this >= terabyte) {
|
||||
String.format(ENGLISH, "%.2f", (this.toDouble() / terabyte.toDouble())) + "TB"
|
||||
} else {
|
||||
"$this" + "B"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
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,40 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
val Context.wifiManager: WifiManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
fun Context.isOnline(): Boolean {
|
||||
val activeNetwork = connectivityManager.activeNetwork ?: return false
|
||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
|
||||
val maxTransport = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 -> NetworkCapabilities.TRANSPORT_LOWPAN
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> NetworkCapabilities.TRANSPORT_WIFI_AWARE
|
||||
else -> NetworkCapabilities.TRANSPORT_VPN
|
||||
}
|
||||
return (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(networkCapabilities::hasTransport)
|
||||
}
|
||||
|
||||
fun Context.isConnectedToWifi(): Boolean {
|
||||
if (!wifiManager.isWifiEnabled) return false
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val activeNetwork = connectivityManager.activeNetwork ?: return false
|
||||
val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
|
||||
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
wifiManager.connectionInfo.bssid != null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationChannelGroupCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag
|
||||
import androidx.core.content.PermissionChecker
|
||||
import androidx.core.content.getSystemService
|
||||
import com.illusionware.npsbrowser.ui.theme.ColorAccent
|
||||
|
||||
val Context.notificationManager: NotificationManager
|
||||
get() = getSystemService()!!
|
||||
|
||||
fun Context.notify(id: Int, channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null) {
|
||||
val notification = notificationBuilder(channelId, block).build()
|
||||
this.notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(id, notification)
|
||||
}
|
||||
|
||||
fun Context.notify(notificationWithIdAndTags: List<NotificationWithIdAndTag>) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && PermissionChecker.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PermissionChecker.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
|
||||
NotificationManagerCompat.from(this).notify(notificationWithIdAndTags)
|
||||
}
|
||||
|
||||
fun Context.cancelNotification(id: Int) {
|
||||
NotificationManagerCompat.from(this).cancel(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a notification builder.
|
||||
*
|
||||
* @param channelId the channel id.
|
||||
* @param block the function that will execute inside the builder.
|
||||
* @return a notification to be displayed or updated.
|
||||
*/
|
||||
fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Builder.() -> Unit)? = null): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setColor(ColorAccent.toArgb())
|
||||
if (block != null) {
|
||||
builder.block()
|
||||
}
|
||||
return builder
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.illusionware.npsbrowser.util.system
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
|
||||
/**
|
||||
* Saves the given source to a file and closes it. Directories will be created if needed.
|
||||
*
|
||||
* @param file the file where the source is copied.
|
||||
*/
|
||||
//fun BufferedSource.saveTo(file: File) {
|
||||
// try {
|
||||
// // Create parent dirs if needed
|
||||
// file.parentFile?.mkdirs()
|
||||
//
|
||||
// // Copy to destination
|
||||
// saveTo(file.outputStream())
|
||||
// } catch (e: Exception) {
|
||||
// close()
|
||||
// file.delete()
|
||||
// throw e
|
||||
// }
|
||||
//}
|
||||
|
||||
fun BufferedSource.saveTo(sink: BufferedSink, totalBytes: Long, bytesRead: Long, scope: CoroutineScope): Boolean {
|
||||
var downloaded = bytesRead
|
||||
|
||||
use { input ->
|
||||
sink.use { out ->
|
||||
while (true) {
|
||||
scope.ensureActive()
|
||||
val read = input.read(out.buffer, DEFAULT_BUFFER_SIZE.toLong())
|
||||
if (read == -1L) break
|
||||
downloaded += read
|
||||
out.emitCompleteSegments()
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
|
||||
if (totalBytes <= 0L) return true
|
||||
val expected = bytesRead + totalBytes
|
||||
return downloaded >= expected
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the given source to an output stream and closes both resources.
|
||||
*
|
||||
* @param stream the stream where the source is copied.
|
||||
*/
|
||||
//fun BufferedSource.saveTo(stream: OutputStream, scope: CoroutineScope) {
|
||||
// use { input ->
|
||||
// stream.sink().buffer().use {
|
||||
// if (scope.isActive) {
|
||||
// it.writeAll(input)
|
||||
// it.flush()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,57 @@
|
||||
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.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class OnboardingViewModel(
|
||||
private val onboardingPreferencesRepository: OnboardingPreferencesRepository
|
||||
): ViewModel() {
|
||||
val uiState: StateFlow<OnboardingPreferences> =
|
||||
onboardingPreferencesRepository.getPreferences().stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(2_000),
|
||||
OnboardingPreferences(
|
||||
false,
|
||||
)
|
||||
)
|
||||
|
||||
suspend fun getOnboardingStatus(): Boolean {
|
||||
return onboardingPreferencesRepository.getPreferences().first().seenOnboarding
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.illusionware.npsbrowser.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.illusionware.npsbrowser.NPSApp
|
||||
import com.illusionware.npsbrowser.data.download.DownloadManager
|
||||
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(val downloadManager: DownloadManager): ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SelectedPackageUiState(null))
|
||||
val uiState: StateFlow<SelectedPackageUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun setSelectedPackage(item: PackageItem) {
|
||||
_uiState.value = SelectedPackageUiState(item)
|
||||
}
|
||||
|
||||
fun removeDownload(url: String) = downloadManager.cancelDownload(url)
|
||||
fun pauseDownload(url: String) = downloadManager.pauseDownload(url)
|
||||
fun resumeDownload(url: String) = downloadManager.resumeDownload(url)
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = (this[APPLICATION_KEY] as NPSApp)
|
||||
PackageDetailsViewModel(application.downloadManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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.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 com.illusionware.npsbrowser.util.system.prettyByte
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.internal.toLongOrDefault
|
||||
import java.io.InputStream
|
||||
import androidx.core.net.toUri
|
||||
import java.util.Locale.ENGLISH
|
||||
|
||||
data class PackageListUiState(
|
||||
val packages: ArrayList<PackageItem> = ArrayList(),
|
||||
val error: String = "",
|
||||
val isLoading: Boolean = true,
|
||||
val isSearchOpen: Boolean = false,
|
||||
val searchQuery: String = "",
|
||||
)
|
||||
|
||||
class PackageListViewModel(app: NPSApp, settingsRepo: SettingsPreferencesRepository): ViewModel() {
|
||||
private val _uiState = MutableStateFlow(PackageListUiState())
|
||||
private val immutablePackages = ArrayList<PackageItem>()
|
||||
val uiState: StateFlow<PackageListUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val tsvReader = csvReader {
|
||||
charset = "UTF-8"
|
||||
quoteChar = '"'
|
||||
delimiter = '\t'
|
||||
excessFieldsRowBehaviour = ExcessFieldsRowBehaviour.TRIM
|
||||
insufficientFieldsRowBehaviour = InsufficientFieldsRowBehaviour.EMPTY_STRING
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val list = ArrayList<PackageItem>()
|
||||
|
||||
settingsRepo.availableTsvs().forEach {
|
||||
list += parsePackageFromTSV(it, app)
|
||||
}
|
||||
|
||||
list.sortBy { it.name }
|
||||
|
||||
_uiState.emit(PackageListUiState(isLoading = false, packages = list))
|
||||
immutablePackages.addAll(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))
|
||||
|
||||
immutablePackages.addAll(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
packages = immutablePackages.filter { item ->
|
||||
item.name.contains(query, true)
|
||||
} as ArrayList<PackageItem>,
|
||||
searchQuery = query
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun openSearch() {
|
||||
_uiState.update {
|
||||
it.copy(isSearchOpen = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSearch() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
packages = immutablePackages,
|
||||
searchQuery = "",
|
||||
isSearchOpen = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
_uiState.update {
|
||||
it.copy(packages = immutablePackages, searchQuery = "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePackageFromTSV(
|
||||
it: Triple<String, ConsoleType, PackageItemType>, app: NPSApp
|
||||
): ArrayList<PackageItem> {
|
||||
val list = ArrayList<PackageItem>()
|
||||
|
||||
val fileUri = it.first
|
||||
val console = it.second
|
||||
val type = it.third
|
||||
|
||||
val file: InputStream?
|
||||
try {
|
||||
file = app.contentResolver.openInputStream(fileUri.toUri())
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
}
|
||||
val tsv = tsvReader.readAllWithHeader(file!!)
|
||||
|
||||
tsv.forEach { row ->
|
||||
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 = tempSize.toLongOrDefault(0).prettyByte()
|
||||
}
|
||||
if (!tempFW.isNullOrEmpty()) {
|
||||
minFW = String.format(ENGLISH, "%.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"]?.trim() ?: "",
|
||||
pkgUrl,
|
||||
contentId,
|
||||
modificationDate,
|
||||
size,
|
||||
tempSize?.toULongOrNull(),
|
||||
sha256,
|
||||
zRif,
|
||||
rap,
|
||||
minFW,
|
||||
console,
|
||||
type,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
file.close()
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = (this[APPLICATION_KEY] as NPSApp)
|
||||
PackageListViewModel(application, application.settingsPreferencesRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.illusionware.npsbrowser.viewmodels
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.illusionware.npsbrowser.NPSApp
|
||||
import com.illusionware.npsbrowser.data.ItemLayout
|
||||
import com.illusionware.npsbrowser.data.SettingsPreferences
|
||||
import com.illusionware.npsbrowser.data.SettingsPreferencesRepository
|
||||
import com.illusionware.npsbrowser.data.Theme
|
||||
import com.illusionware.npsbrowser.model.ConsoleType
|
||||
import com.illusionware.npsbrowser.model.PackageItemType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(private val settingsPrefsRepo: SettingsPreferencesRepository): ViewModel() {
|
||||
val uiState: StateFlow<SettingsPreferences> =
|
||||
settingsPrefsRepo.getPreferences().stateIn(viewModelScope, SharingStarted.Lazily, SettingsPreferences(
|
||||
Theme.SYSTEM.ordinal,
|
||||
ItemLayout.LIST.ordinal,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
"",
|
||||
true,
|
||||
))
|
||||
|
||||
fun availableTsvs(): MutableList<Pair<ConsoleType, PackageItemType>> {
|
||||
val list = mutableListOf<Pair<ConsoleType, PackageItemType>>()
|
||||
if (uiState.value.psvGames.isNotEmpty()) list.add(Pair(ConsoleType.PSVITA, PackageItemType.GAME))
|
||||
if (uiState.value.ps3Games.isNotEmpty()) list.add(Pair(ConsoleType.PS3, PackageItemType.GAME))
|
||||
if (uiState.value.pspGames.isNotEmpty()) list.add(Pair(ConsoleType.PSP, PackageItemType.GAME))
|
||||
if (uiState.value.psxGames.isNotEmpty()) list.add(Pair(ConsoleType.PSX, PackageItemType.GAME))
|
||||
if (uiState.value.psmGames.isNotEmpty()) list.add(Pair(ConsoleType.PSM, PackageItemType.GAME))
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
suspend fun getAppTheme(): Int {
|
||||
return settingsPrefsRepo.getPreferences().first().appTheme
|
||||
}
|
||||
|
||||
fun shouldCreateDefaultDir(): Boolean {
|
||||
val defaultDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath + "/NPSBrowser"
|
||||
|
||||
return uiState.value.downloadDir == defaultDir
|
||||
|| uiState.value.unpackDir == defaultDir
|
||||
}
|
||||
|
||||
fun setTSVFile(console: ConsoleType, itemType: PackageItemType, file: Uri) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsPrefsRepo.setTSVFile(console, itemType, file)
|
||||
}
|
||||
}
|
||||
|
||||
fun setTheme(theme: Theme) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsPrefsRepo.setTheme(theme)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLayout(layout: ItemLayout) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsPrefsRepo.setLayout(layout)
|
||||
}
|
||||
}
|
||||
|
||||
fun 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
|
||||
<solid android:color="@android:color/holo_blue_dark" />
|
||||
<stroke android:width="2dip" android:color="@android:color/holo_blue_dark"/>
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
|
||||
<solid android:color="@android:color/holo_red_dark" />
|
||||
<stroke android:width="2dip" android:color="@android:color/holo_red_dark"/>
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" android:opacity="opaque">
|
||||
|
||||
<!-- The background color -->
|
||||
<item android:drawable="@drawable/splash_gradient"/>
|
||||
|
||||
<!-- App logo -->
|
||||
<item
|
||||
android:drawable="@drawable/ic_nps_fg_no_shadow"
|
||||
android:gravity="center"
|
||||
android:width="400dp"
|
||||
android:height="400dp" />
|
||||
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,20 @@
|
||||
<vector android:height="256dp" android:viewportHeight="100"
|
||||
android:viewportWidth="100" android:width="256dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#ee3e54" android:pathData="M13,27A2,2 0,1 0,13 31A2,2 0,1 0,13 27Z"/>
|
||||
<path android:fillColor="#f1bc19" android:pathData="M77,12A1,1 0,1 0,77 14A1,1 0,1 0,77 12Z"/>
|
||||
<path android:fillColor="#fce0a2" android:pathData="M50,13A37,37 0,1 0,50 87A37,37 0,1 0,50 13Z"/>
|
||||
<path android:fillColor="#f1bc19" android:pathData="M83,11A4,4 0,1 0,83 19A4,4 0,1 0,83 11Z"/>
|
||||
<path android:fillColor="#ee3e54" android:pathData="M87,22A2,2 0,1 0,87 26A2,2 0,1 0,87 22Z"/>
|
||||
<path android:fillColor="#fbcd59" android:pathData="M81,74A2,2 0,1 0,81 78,2 2,0 1,0 81,74zM15,59A4,4 0,1 0,15 67,4 4,0 1,0 15,59z"/>
|
||||
<path android:fillColor="#ee3e54" android:pathData="M25,85A2,2 0,1 0,25 89A2,2 0,1 0,25 85Z"/>
|
||||
<path android:fillColor="#fff" android:pathData="M18.5,51A2.5,2.5 0,1 0,18.5 56A2.5,2.5 0,1 0,18.5 51Z"/>
|
||||
<path android:fillColor="#f1bc19" android:pathData="M21,66A1,1 0,1 0,21 68A1,1 0,1 0,21 66Z"/>
|
||||
<path android:fillColor="#fff" android:pathData="M80,33A1,1 0,1 0,80 35A1,1 0,1 0,80 33Z"/>
|
||||
<path android:fillColor="#cdcbbd" android:pathData="M29.51,69.3c-0.5,0 -1.13,-0.14 -1.48,-1.27c-0.27,-0.87 -0.27,-1.95 -0.27,-2.65L27.7,37.8v-3.82c0,-1.69 1.32,-3.07 2.94,-3.07h17.65c1.66,0 3.01,1.46 3.01,3.24v1.46c0,1.24 0.97,2.26 2.16,2.26h16.09c0.42,0 0.76,0.38 0.76,0.86v23.57c0,0.33 0.01,0.75 0.03,1.21c0.05,1.7 0.13,4.46 -0.44,5.8H29.51z"/>
|
||||
<path android:fillColor="#472b29" android:pathData="M48.29,31.6c1.27,0 2.31,1.14 2.31,2.54v1.46c0,1.63 1.28,2.96 2.86,2.96l16.08,-0c0.01,0 0.07,0.05 0.07,0.16v23.57c0,0.34 0.01,0.76 0.03,1.23c0.04,1.29 0.11,3.74 -0.24,5.08h-39.88c-0.27,0 -0.57,0 -0.81,-0.79c-0.24,-0.77 -0.24,-1.78 -0.24,-2.44l0,-0.09L28.4,37.8v-3.82c0,-1.31 1.01,-2.37 2.24,-2.37H48.29M48.29,30.2H30.65c-2.01,0 -3.64,1.7 -3.64,3.77v3.82l0.05,27.48C27.05,66.7 27,70 29.51,70h40.8C71.37,68.36 71,64.1 71,62.28V38.72c0,-0.86 -0.65,-1.56 -1.46,-1.56H53.46c-0.81,0 -1.46,-0.7 -1.46,-1.56v-1.46C52,31.98 50.33,30.2 48.29,30.2L48.29,30.2z"/>
|
||||
<path android:fillColor="#472b29" android:pathData="M41.5,35h-11c-0.28,0 -0.5,-0.22 -0.5,-0.5s0.22,-0.5 0.5,-0.5h11c0.28,0 0.5,0.22 0.5,0.5S41.78,35 41.5,35z"/>
|
||||
<path android:fillColor="#472b29" android:pathData="M48.5,35h-4c-0.28,0 -0.5,-0.22 -0.5,-0.5s0.22,-0.5 0.5,-0.5h4c0.28,0 0.5,0.22 0.5,0.5S48.78,35 48.5,35z"/>
|
||||
<path android:fillColor="#fdfcee" android:pathData="M29.48,69.3c-0.61,0 -1.11,-0.52 -1.14,-1.18L34.03,42.7h41.92l-5.64,25.37c-0.01,0.05 -0.02,0.1 -0.02,0.15c0,0.29 -0.11,0.57 -0.3,0.77c-0.18,0.19 -0.43,0.3 -0.68,0.3H29.48z"/>
|
||||
<path android:fillColor="#472b29" android:pathData="M75.07,43.4l-5.45,24.52c-0.02,0.1 -0.03,0.2 -0.03,0.31c0,0.12 -0.04,0.23 -0.12,0.3c-0.05,0.05 -0.11,0.08 -0.17,0.08H29.48c-0.21,0 -0.39,-0.19 -0.43,-0.44L34.59,43.4H75.07M76.82,42H33.47l-5.83,26.05c0,1.08 0.82,1.95 1.84,1.95h39.83c0.93,0 1.68,-0.8 1.68,-1.78L76.82,42L76.82,42z"/>
|
||||
<path android:fillColor="#472b29" android:pathData="M50.5,55h-14c-0.15,0 -0.3,-0.07 -0.39,-0.19s-0.13,-0.28 -0.09,-0.43l1.5,-6C37.57,48.16 37.77,48 38,48h14c0.15,0 0.3,0.07 0.39,0.19s0.13,0.28 0.09,0.43l-1.5,6C50.93,54.84 50.73,55 50.5,55zM37.14,54h12.97l1.25,-5H38.39L37.14,54z"/>
|
||||
</vector>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +0,0 @@
|
||||
<vector android:height="24dp" android:tint="#FFCA00"
|
||||
android:viewportHeight="24.0" android:viewportWidth="24.0"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z"/>
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:pathData="M208,76L100,76L100,56a28,28 0,0 1,28 -28c13.51,0 25.65,9.62 28.24,22.39a12,12 0,1 0,23.52 -4.78C174.87,21.5 153.1,4 128,4A52.06,52.06 0,0 0,76 56L76,76L48,76A20,20 0,0 0,28 96L28,208a20,20 0,0 0,20 20L208,228a20,20 0,0 0,20 -20L228,96A20,20 0,0 0,208 76ZM204,204L52,204L52,100L204,100ZM112,152a16,16 0,1 1,16 16A16,16 0,0 1,112 152Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector android:height="24dp" android:viewportHeight="12"
|
||||
android:viewportWidth="12" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<group>
|
||||
<clip-path android:pathData="M0,0h12v12h-12z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M6,1C3.24,1 1,3.24 1,6C1,8.76 3.24,11 6,11C8.76,11 11,8.76 11,6C11,3.24 8.76,1 6,1ZM6,10C3.79,10 2,8.21 2,6C2,5.075 2.315,4.225 2.845,3.55L8.45,9.155C7.775,9.685 6.925,10 6,10ZM9.155,8.45L3.55,2.845C4.225,2.315 5.075,2 6,2C8.21,2 10,3.79 10,6C10,6.925 9.685,7.775 9.155,8.45Z"/>
|
||||
<group>
|
||||
<clip-path android:pathData="M2,2h8v8h-8z"/>
|
||||
<path android:fillColor="#000000" android:fillType="evenOdd" android:pathData="M5.2,4.8C5.2,4.38 5.332,3.972 5.577,3.631C5.823,3.291 6.169,3.036 6.567,2.903C6.965,2.77 7.395,2.766 7.795,2.891C8.196,3.016 8.547,3.264 8.799,3.599C9.051,3.934 9.191,4.341 9.2,4.76C9.208,5.18 9.084,5.591 8.846,5.936C8.608,6.282 8.267,6.543 7.872,6.684C7.477,6.825 7.047,6.838 6.644,6.722L5.883,7.483C5.808,7.558 5.706,7.6 5.6,7.6H5.2V8C5.2,8.106 5.158,8.208 5.083,8.283C5.008,8.358 4.906,8.4 4.8,8.4H4.4V8.8C4.4,8.906 4.358,9.008 4.283,9.083C4.208,9.158 4.106,9.2 4,9.2H3.2C3.094,9.2 2.992,9.158 2.917,9.083C2.842,9.008 2.8,8.906 2.8,8.8V8C2.8,7.894 2.842,7.792 2.917,7.717L5.278,5.356C5.226,5.175 5.2,4.988 5.2,4.8ZM7.2,3.6C7.12,3.6 7.044,3.632 6.988,3.688C6.932,3.744 6.9,3.82 6.9,3.9C6.9,3.98 6.932,4.056 6.988,4.112C7.044,4.168 7.12,4.2 7.2,4.2C7.359,4.2 7.512,4.263 7.624,4.376C7.737,4.488 7.8,4.641 7.8,4.8C7.8,4.88 7.832,4.956 7.888,5.012C7.944,5.068 8.02,5.1 8.1,5.1C8.18,5.1 8.256,5.068 8.312,5.012C8.368,4.956 8.4,4.88 8.4,4.8C8.4,4.482 8.274,4.177 8.049,3.951C7.823,3.726 7.518,3.6 7.2,3.6Z"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector android:height="24dp" android:viewportHeight="12"
|
||||
android:viewportWidth="12" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<group>
|
||||
<clip-path android:pathData="M0,0h12v12h-12z"/>
|
||||
<path android:fillColor="#000000" android:pathData="M6,1C3.24,1 1,3.24 1,6C1,8.76 3.24,11 6,11C8.76,11 11,8.76 11,6C11,3.24 8.76,1 6,1ZM6,10C3.79,10 2,8.21 2,6C2,5.075 2.315,4.225 2.845,3.55L8.45,9.155C7.775,9.685 6.925,10 6,10ZM9.155,8.45L3.55,2.845C4.225,2.315 5.075,2 6,2C8.21,2 10,3.79 10,6C10,6.925 9.685,7.775 9.155,8.45Z"/>
|
||||
<path android:fillColor="#00000000"
|
||||
android:pathData="M5.822,4.231L6.441,3.612C6.703,3.374 7.046,3.245 7.401,3.253C7.755,3.261 8.093,3.406 8.343,3.657C8.594,3.907 8.739,4.245 8.747,4.599C8.755,4.953 8.626,5.297 8.387,5.559L7.503,6.441C7.376,6.569 7.224,6.67 7.057,6.739C6.891,6.809 6.712,6.844 6.531,6.844C6.351,6.844 6.172,6.809 6.005,6.739C5.838,6.67 5.687,6.569 5.559,6.441"
|
||||
android:strokeColor="#000000" android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" android:strokeWidth="1"/>
|
||||
<path android:fillColor="#00000000"
|
||||
android:pathData="M6.178,7.769L5.559,8.387C5.297,8.626 4.954,8.755 4.599,8.747C4.245,8.739 3.907,8.594 3.657,8.343C3.406,8.093 3.261,7.755 3.253,7.401C3.245,7.047 3.374,6.703 3.613,6.441L4.497,5.559C4.624,5.431 4.776,5.33 4.943,5.261C5.109,5.191 5.288,5.156 5.469,5.156C5.649,5.156 5.828,5.191 5.995,5.261C6.162,5.33 6.313,5.431 6.441,5.559"
|
||||
android:strokeColor="#000000" android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" android:strokeWidth="1"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,118 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="51.512dp"
|
||||
android:height="31.41dp"
|
||||
android:viewportWidth="51.512"
|
||||
android:viewportHeight="31.41">
|
||||
<path
|
||||
android:pathData="m14.423,19.84v-8.27h2.22c1,1.85 2.94,5.2 3.32,6.1v0c-0.09,-0.86 -0.09,-2.29 -0.09,-3.65v-2.45h1.62v8.27h-2.12c-0.89,-1.62 -3,-5.47 -3.42,-6.42v0c0.06,0.72 0.08,2.42 0.08,3.9v2.52z"
|
||||
android:fillColor="#fff"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m23.582,11.57h3.42c1.76,0 3,1 3,2.56 0,1.79 -1.36,2.62 -3,2.62h-1.63v3.09h-1.79zM25.372,15.33h1.37c0.82,0 1.41,-0.32 1.41,-1.17 0,-0.85 -0.62,-1.17 -1.38,-1.17h-1.4z"
|
||||
android:fillColor="#fff"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m32.592,17.435a1.54,1.54 0,0 0,1.74 1.2c1,0 1.37,-0.38 1.37,-1 0,-0.62 -0.4,-1 -1.82,-1.3 -2.25,-0.53 -2.8,-1.35 -2.8,-2.46 0,-1.44 1.07,-2.42 3,-2.42 2.2,0 3.07,1.18 3.19,2.39h-1.81c-0.09,-0.51 -0.37,-1.06 -1.43,-1.06 -0.73,0 -1.17,0.3 -1.17,0.88 0,0.58 0.35,0.81 1.69,1.12 2.4,0.57 2.93,1.46 2.93,2.63 0,1.51 -1.14,2.54 -3.33,2.54 -2.19,0 -3.2,-1 -3.4,-2.54z"
|
||||
android:fillColor="#fff"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m10.951,3.846v0.75z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m3.621,13.07h1.54,0.06l0.71,0.73a0.14,0.14 0,0 1,0 0.19l-0.71,0.73a0.09,0.09 0,0 1,-0.06 0h-1.54a0.1,0.1 0,0 1,-0.09 -0.11v-1.43a0.1,0.1 0,0 1,0.09 -0.11z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m3.603,12.694 l-0.018,0.002c-0.258,0.025 -0.455,0.265 -0.428,0.523l-0.002,-0.039v1.43l0.002,-0.039c-0.027,0.258 0.169,0.497 0.428,0.521l0.018,0.002h1.559l-0.125,-0.021c0.1,0.035 0.21,0.035 0.311,0l0.082,-0.029 0.773,-0.795 0.004,-0.004c0.18,-0.195 0.18,-0.504 0,-0.699l-0.004,-0.004 -0.822,-0.848h-0.219zM3.905,13.444h1.156l0.438,0.451 -0.438,0.449h-1.156z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m5.891,17.05v-1.54a0.05,0.05 0,0 1,0 -0.05l0.73,-0.72a0.17,0.17 0,0 1,0.19 0l0.74,0.72a0.08,0.08 0,0 1,0 0.05v1.54a0.11,0.11 0,0 1,-0.11 0.1h-1.43a0.11,0.11 0,0 1,-0.12 -0.1z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m6.411,14.429 l-0.029,0.02 -0.791,0.781 -0.025,0.043c-0.076,0.131 -0.076,0.295 0,0.426l-0.049,-0.188v1.557,0.016c0.023,0.264 0.266,0.466 0.529,0.441h1.395c0.247,0 0.462,-0.193 0.484,-0.439v-0.018,-1.557l-0.018,0.117c0.03,-0.092 0.03,-0.193 0,-0.285l-0.029,-0.088 -0.83,-0.807 -0.027,-0.02c-0.183,-0.123 -0.426,-0.123 -0.609,0zM6.718,15.171 L7.175,15.618v1.156h-0.908v-1.158z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m9.871,14.78h-1.53a0.06,0.06 0,0 1,-0.06 0L7.561,14.05a0.17,0.17 0,0 1,0 -0.19l0.72,-0.73a0.06,0.06 0,0 1,0.06 0h1.53a0.1,0.1 0,0 1,0.1 0.11L9.971,14.7a0.1,0.1 0,0 1,-0.1 0.08z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m8.341,12.755 l0.188,0.051c-0.134,-0.077 -0.301,-0.077 -0.436,0l-0.043,0.025 -0.781,0.791 -0.02,0.029c-0.123,0.183 -0.123,0.424 0,0.607l0.02,0.029 0.781,0.791 0.043,0.025c0.134,0.077 0.301,0.077 0.436,0l-0.188,0.051h1.521c0.227,0.005 0.431,-0.159 0.477,-0.381l0.008,-0.037v-1.498l-0.002,0.039c0.028,-0.275 -0.201,-0.525 -0.477,-0.523zM8.439,13.505h1.158v0.9h-1.158l-0.445,-0.451z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m7.601,10.8v1.54a0.14,0.14 0,0 1,0 0.06l-0.74,0.71a0.14,0.14 0,0 1,-0.19 0L5.941,12.4a0.09,0.09 0,0 1,0 -0.06L5.941,10.8c0,-0.05 0,-0.09 0.12,-0.09h1.48a0.1,0.1 0,0 1,0.06 0.09z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m6.062,10.335c-0.102,0 -0.249,0.004 -0.381,0.146 -0.132,0.143 -0.115,0.293 -0.115,0.318v1.541l0.021,-0.125c-0.035,0.1 -0.035,0.208 0,0.309l0.029,0.084 0.797,0.773 0.004,0.004c0.195,0.18 0.502,0.18 0.697,0l0.004,-0.002 0.824,-0.791 0.025,-0.111c0.016,-0.073 0.016,-0.148 0,-0.221l0.008,0.08v-1.543,-0.004c-0.003,-0.185 -0.115,-0.354 -0.285,-0.428l-0.072,-0.031zM6.316,11.085h0.91v1.156l-0.459,0.439 -0.451,-0.439z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m42.812,19.171c-1.007,0 -1.83,0.823 -1.83,1.83 0,1.007 0.823,1.83 1.83,1.83 1.007,0 1.83,-0.823 1.83,-1.83 0,-1.007 -0.823,-1.83 -1.83,-1.83zM42.812,19.77c0.683,0 1.229,0.548 1.229,1.23 0,0.683 -0.546,1.229 -1.229,1.229 -0.683,0 -1.23,-0.546 -1.23,-1.229 0,-0.683 0.548,-1.23 1.23,-1.23z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m8.71,19.171c-1.007,0 -1.83,0.823 -1.83,1.83 0,1.007 0.823,1.83 1.83,1.83 1.007,0 1.83,-0.823 1.83,-1.83 0,-1.007 -0.823,-1.83 -1.83,-1.83zM8.71,19.77c0.683,0 1.23,0.548 1.23,1.23 0,0.683 -0.548,1.229 -1.23,1.229 -0.683,0 -1.229,-0.546 -1.229,-1.229 0,-0.683 0.546,-1.23 1.229,-1.23z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m43.441,14.02a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m42.392,12.595c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM42.392,13.345c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m45.941,16.52a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m44.892,15.095c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM44.892,15.845c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m48.441,14.02a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m47.392,12.595c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM47.392,13.345c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m45.941,11.52a1.05,1.05 0,0 1,-1.05 1.05,1.05 1.05,0 0,1 -1.05,-1.05 1.05,1.05 0,0 1,1.05 -1.05,1.05 1.05,0 0,1 1.05,1.05z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m44.892,10.095c-0.783,0 -1.426,0.643 -1.426,1.426 0,0.783 0.643,1.424 1.426,1.424 0.783,0 1.424,-0.641 1.424,-1.424 0,-0.783 -0.641,-1.426 -1.424,-1.426zM44.892,10.845c0.377,0 0.674,0.299 0.674,0.676 0,0.377 -0.297,0.674 -0.674,0.674 -0.377,0 -0.676,-0.297 -0.676,-0.674 0,-0.377 0.299,-0.676 0.676,-0.676z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m10.99,7.32v0.5,16.26h29.531L40.521,7.32ZM11.99,8.32h27.531v14.76h-27.531z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M12.201,3.5C5.454,3.5 0,8.952 0,15.699v0.012c0,6.748 5.454,12.199 12.201,12.199h27.109c6.748,0 12.201,-5.452 12.201,-12.199v-0.012C51.512,8.952 46.058,3.5 39.311,3.5ZM12.201,4.801h27.109c6.05,0 10.9,4.849 10.9,10.898v0.012c0,6.05 -4.851,10.898 -10.9,10.898L12.201,26.609c-6.05,0 -10.9,-4.849 -10.9,-10.898v-0.012c0,-6.05 4.851,-10.898 10.9,-10.898z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m5.637,24.529 l-0.012,1 4.623,0.051h0.309c-0.109,-0.014 0.023,0.022 0.158,0.186 0.138,0.167 0.298,0.413 0.441,0.66 0.287,0.495 0.508,0.988 0.508,0.988l0.912,-0.408c0,0 -0.236,-0.532 -0.555,-1.082 -0.159,-0.275 -0.337,-0.555 -0.535,-0.795 -0.198,-0.24 -0.383,-0.488 -0.797,-0.545l-0.035,-0.004h-0.4z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m11.664,4.025c0,0 -0.221,0.493 -0.508,0.988 -0.143,0.247 -0.303,0.495 -0.441,0.662 -0.135,0.163 -0.268,0.2 -0.158,0.186 -0.088,0.006 -0.176,0.007 -0.264,0l-0.023,-0.002 -4.645,0.051 0.012,1 4.619,-0.051 -0.047,-0.002c0.151,0.013 0.302,0.013 0.453,0h0.014l0.014,-0.002c0.414,-0.057 0.599,-0.303 0.797,-0.543 0.198,-0.24 0.376,-0.522 0.535,-0.797 0.318,-0.55 0.555,-1.08 0.555,-1.08z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m45.756,24.529 l-4.627,0.051h-0.393l-0.033,0.004c-0.412,0.056 -0.602,0.305 -0.801,0.545 -0.199,0.24 -0.376,0.522 -0.535,0.797 -0.318,0.551 -0.553,1.082 -0.553,1.082l0.914,0.404c0,0 0.219,-0.492 0.504,-0.986 0.142,-0.247 0.302,-0.494 0.439,-0.66 0.135,-0.163 0.266,-0.199 0.162,-0.186h0.299l4.633,-0.051z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="m39.729,4.027 l-0.914,0.406c0,0 0.235,0.531 0.553,1.082 0.159,0.275 0.336,0.557 0.535,0.797 0.199,0.24 0.388,0.487 0.801,0.543l0.012,0.002h0.014c0.148,0.013 0.297,0.013 0.445,0l-0.049,0.002 4.631,0.051 0.01,-1 -4.654,-0.051 -0.023,0.002c-0.085,0.007 -0.169,0.006 -0.254,0 0.104,0.013 -0.027,-0.023 -0.162,-0.186C40.534,5.509 40.375,5.263 40.232,5.016 39.947,4.521 39.729,4.027 39.729,4.027Z"
|
||||
android:fillColor="#2f2f2f"
|
||||
android:strokeColor="#00000000"/>
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="32dp"
|
||||
android:height="32dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:pathData="M128,24A104,104 0,1 0,232 128,104.11 104.11,0 0,0 128,24ZM128,216a88,88 0,1 1,88 -88A88.1,88.1 0,0 1,128 216ZM144,176a8,8 0,0 1,-8 8,16 16,0 0,1 -16,-16L120,128a8,8 0,0 1,0 -16,16 16,0 0,1 16,16v40A8,8 0,0 1,144 176ZM112,84a12,12 0,1 1,12 12A12,12 0,0 1,112 84Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector android:height="24dp" android:viewportHeight="425.72"
|
||||
android:viewportWidth="425.72" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#000000"
|
||||
android:pathData="m70.82,87.19c-6.6,1.9 -7.7,2.7 -9.1,6.9 -0.5,1.8 -2.3,4 -4,4.9 -4.3,2.6 -5.6,5.9 -7,17.9 -1.2,9.7 -1.5,10.7 -3.8,11.9 -4.9,2.7 -15,14.9 -19.5,23.7 -7.3,14.2 -9.7,23.8 -17.6,71.7 -8.9,53.3 -10.3,63.7 -9.7,71.5 1.1,16.1 9.2,31.1 20,37.5 13.7,8 31.4,9.1 44.9,2.7 10.9,-5.1 14.4,-9.5 36.1,-45l11.8,-19.3 5.5,6.1c3,3.3 7.5,7.2 9.9,8.6 22,12.9 50.7,5.2 62.3,-16.6l3.2,-6l20.4,-0 20.5,-0l2.6,5.1c3.2,6.4 10.4,14.2 16.2,17.6 17.9,10.5 41.6,7.3 55.3,-7.5 2.2,-2.3 4.1,-4.2 4.3,-4.2 0.2,-0 7,10.3 15,22.8 8,12.6 16.2,24.7 18.2,27 8,9.4 18.8,14.2 32,14.2 24.4,-0 39.7,-11.3 45.4,-33.6 1.1,-4.2 2,-9.7 2,-12.1 0,-9.3 -14.7,-106.4 -18.5,-122.1 -0.9,-3.7 -3.7,-11 -6.2,-16.2 -3.7,-7.7 -6,-10.8 -12.2,-17 -4.2,-4.2 -8.6,-8.3 -9.9,-9.1 -2,-1.2 -2.5,-2.8 -3.8,-12.9 -1.7,-12.7 -2.6,-14.8 -8.3,-18.6 -2.2,-1.4 -4.2,-3.6 -4.5,-4.8 -1,-4.1 -10.3,-6.6 -24.6,-6.6 -14,-0 -23.4,2.5 -24.6,6.4 -0.4,1.1 -2.6,3.8 -5,5.9 -4.7,4.1 -5.7,6.6 -7.4,17.7 -0.6,4.1 -1.3,8.5 -1.6,9.7l-0.4,2.3l-36.1,-0c-31.7,-0 -36.4,0.2 -39.2,1.7 -4.4,2.2 -16.9,2.3 -22.1,-0 -5.2,-2.3 -18.6,-2.4 -18.6,-0.2 0,0.8 -0.9,1.5 -2,1.5 -1.1,-0 -2,-0.7 -2,-1.5 0,-1.1 -1.2,-1.5 -4.5,-1.5 -3.3,-0 -4.5,0.4 -4.5,1.5 0,0.8 -0.7,1.5 -1.5,1.5 -0.8,-0 -1.5,-0.7 -1.5,-1.5 0,-1.1 -1.2,-1.5 -4.4,-1.5 -3,-0 -4.6,0.5 -5,1.5 -0.3,0.8 -1.2,1.5 -2.1,1.5 -0.8,-0 -1.5,-0.7 -1.5,-1.5 0,-1.1 -1.2,-1.5 -4.5,-1.5 -3.3,-0 -4.5,0.4 -4.5,1.5 0,0.8 -0.9,1.5 -2,1.5 -1.1,-0 -2,-0.7 -2,-1.5 0,-1.2 -1.4,-1.5 -6.5,-1.5 -7.4,-0 -6.6,1.3 -8.9,-13.6 -1.6,-10.1 -3.2,-13.3 -8.1,-16.4 -1.8,-1.1 -4.2,-3.5 -5.3,-5.3 -2.6,-4.6 -7.3,-5.9 -22.7,-6.3 -9.5,-0.3 -14.4,-0 -18.4,1.1zM102.72,122.29c3.6,0.8 10.3,3.3 15,5.6 11.9,5.7 23.8,17.7 29.8,29.9 6.7,13.6 8.8,28.2 6.1,43.6 -0.9,5.3 -1.4,6.3 -3.1,6.3 -1.7,-0 -1.9,-0.4 -1.3,-2.3 1.6,-5.2 1.7,-25.1 0.1,-31.9 -5.4,-23 -24.1,-41.9 -46.9,-47.3 -8,-1.9 -22.5,-1.9 -30.6,-0 -21.9,5.2 -41.4,24.7 -46.6,46.6 -1.9,8.2 -1.9,22.6 0.1,30.9 4.1,17.5 17.1,33.8 33.4,41.9 13.9,6.8 25.7,8.5 40.6,5.6 6.2,-1.2 8,-1.2 8.6,-0.2 1.5,2.4 -6.9,4.1 -20.7,4.1 -11.1,0.1 -14,-0.2 -20,-2.2 -17.5,-5.8 -32.6,-18.6 -40.3,-34.3 -9.6,-19.5 -9.7,-40.9 -0.1,-60.7 5.9,-12.2 17.9,-24.2 29.9,-30 4.7,-2.3 11.2,-4.8 14.5,-5.6 8,-1.9 23.4,-1.9 31.5,-0zM355.12,122.19c22,4.7 42.2,23.1 49.2,44.9 12.3,38.2 -11.3,78.8 -50.7,87.2 -5.2,1.1 -10.5,1.5 -17.9,1.1 -11.4,-0.5 -16,-1.8 -16,-4.3 0,-1.1 0.5,-1.4 1.6,-1 4.1,1.6 16.6,2.8 22.9,2.3 26.5,-2.3 48.8,-20.2 56.7,-45.7 3.4,-11 3.2,-26.4 -0.6,-37.7 -14.8,-45.3 -72.1,-59.1 -105.6,-25.4 -15.4,15.5 -22,37.1 -17.6,57.8 1.2,5.8 1.1,6.3 -0.4,6.3 -4.1,-0 -5.4,-23.3 -2,-36.4 3.1,-12.2 8.2,-20.9 18,-30.6 9.6,-9.7 18.3,-14.9 30.2,-18 8.9,-2.3 22.6,-2.5 32.2,-0.5zM347.42,141.79c13.7,6.6 8.8,27 -6.5,27.2 -8.5,0.1 -14.2,-5.6 -14.2,-14.4 0,-5.6 2.9,-10.4 8,-13 4.5,-2.4 7.4,-2.4 12.7,0.2zM89.72,146.19 L92.02,148.69l-5.9,-0 -5.9,-0l3,-2.5c3.7,-3 3.7,-3 6.5,-0zM96.52,164.09c0.3,7.1 0.1,8.1 -2,10.3 -2.4,2.6 -6.9,5.3 -8.7,5.3 -0.6,-0 -2.9,-1.6 -5.1,-3.6l-4,-3.6l0,-7.7c0,-4.3 0.3,-8.1 0.7,-8.5 0.4,-0.4 4.8,-0.5 9.8,-0.4l9,0.3zM381.62,174.49c10,5 10.6,18.6 1.2,24.9 -9.2,6.2 -22.1,-1 -22.1,-12.2 0,-10.7 11.3,-17.6 20.9,-12.7zM311.82,175.99c9.4,6.3 8.8,19.9 -1.2,24.9 -9.6,4.9 -20.9,-2 -20.9,-12.7 0,-11.2 12.9,-18.4 22.1,-12.2zM72.42,181.89c6,6.5 6,8.2 0,14.5 -2.9,3.1 -3.5,3.3 -9.8,3.3 -9,-0 -9.9,-1 -9.9,-10.5 0,-9.5 0.9,-10.5 9.9,-10.5 6.3,-0 6.9,0.2 9.8,3.2zM117.82,179.69c1.6,0.9 1.9,2.2 1.9,9.3 0,5.1 -0.5,8.8 -1.2,9.5 -0.7,0.7 -4,1.2 -7.9,1.2 -7.5,-0 -10.2,-1.5 -13.4,-7.3 -1.8,-3.4 -1.8,-3.5 0.5,-7.2 1.3,-2 3.7,-4.3 5.4,-5.1 3.7,-1.6 12,-1.8 14.7,-0.4zM188.52,182.29c2.4,1.7 3,7 1.2,10.5 -0.9,1.6 -2.2,1.9 -9.3,1.9 -5.1,-0 -8.8,-0.5 -9.5,-1.2 -1.9,-1.9 -1.5,-10.5 0.6,-11.7 2.8,-1.7 14.4,-1.3 17,0.5zM255.62,185.09c3.8,1.7 4.9,4.2 2.9,6.2 -2.3,2.4 -17,4.5 -20.1,3 -3.2,-1.7 -3.7,-9.8 -0.7,-11.9 1.9,-1.3 2.9,-1.3 8.8,-0 3.7,0.8 7.8,2 9.1,2.7zM131.42,191.49 L128.72,194.19l0,-5.8 0,-5.9l2.7,3.2 2.6,3.2zM45.72,190.29c0,5 -0.2,5.5 -1.6,4.4 -3.3,-2.8 -3.7,-5 -1.3,-7.6 1.2,-1.3 2.4,-2.4 2.6,-2.4 0.2,-0 0.3,2.5 0.3,5.6zM92.82,202.59c3.8,3.7 3.9,4.1 3.9,10.8 0,4 -0.5,7.4 -1.2,8.1 -0.7,0.7 -4.2,1.2 -8.8,1.2 -4.6,-0 -8.1,-0.5 -8.8,-1.2 -0.7,-0.7 -1.2,-4.1 -1.2,-8.4 0,-7.1 0,-7.2 4,-10.8 2.2,-2 5,-3.6 6.1,-3.6 1.2,-0 3.9,1.7 6,3.9zM221.42,204.79c12.1,7.5 6.5,26.9 -7.7,26.9 -5,-0 -10.7,-3.8 -13,-8.5 -2.5,-5.1 -2.5,-6.9 0,-12.2 3.5,-7.9 13.3,-10.8 20.7,-6.2zM349.22,209.09c10.6,8.1 4.4,25.6 -9,25.6 -7.8,-0 -13.6,-5.4 -14.3,-13.2 -0.3,-4.4 0,-5.9 2,-8.8 4.9,-7.3 14.3,-8.9 21.3,-3.6zM163.72,220.29c6.8,3.1 12.3,8.3 15.8,14.9 2.3,4.3 2.7,6.2 2.7,13.5 0,7.3 -0.4,9.2 -2.7,13.6 -7.2,13.8 -23.8,21.1 -38.4,17 -20.1,-5.8 -30.1,-26.6 -21.1,-44.2 8.1,-15.7 27.2,-22.2 43.7,-14.8zM292.32,220.19c20.8,9.4 25.7,36.2 9.2,51 -6.8,6.2 -14,8.9 -23.3,8.9 -9.9,-0 -16.7,-2.8 -23.7,-9.7 -8.1,-8.2 -11,-21 -7.2,-32.1 2,-5.8 10,-14.7 15.8,-17.6 9.3,-4.5 20,-4.7 29.2,-0.5zM90.72,232.29c-2.7,3.2 -4.6,3.4 -7,0.9l-2.3,-2.5l5.2,-0c4.7,-0 5.2,0.2 4.1,1.6z" android:strokeColor="#00000000"/>
|
||||
<path android:fillColor="#000000"
|
||||
android:pathData="m138.22,225.79c-5.1,2.5 -10.1,7.4 -12.8,12.4 -1.7,3.1 -2.2,5.8 -2.2,11.5 0,8.7 2,13.2 8.2,19.2 5.1,4.9 10.5,7 17.9,7 10.3,0.1 18.7,-5.1 23.7,-14.6 3,-5.8 3.1,-17.4 0,-23.1 -2.8,-5.2 -8.8,-11 -13.5,-13 -5.4,-2.2 -16.3,-1.9 -21.3,0.6z" android:strokeColor="#00000000"/>
|
||||
<path android:fillColor="#000000"
|
||||
android:pathData="m268.12,225.59c-15.5,7.1 -20.2,27.5 -9.4,41.1 6.8,8.6 21.4,11.9 31.3,7.1 8.8,-4.3 15.7,-14.9 15.7,-24.1 0,-9.2 -6.8,-19.6 -15.4,-23.6 -6.5,-3 -16.2,-3.2 -22.2,-0.5z" android:strokeColor="#00000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="29.424"
|
||||
android:viewportHeight="29.424">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m14.712,-0c-3.803,0 -7.607,0.701 -10.992,2.103 -0.704,0.292 -1.325,0.913 -1.617,1.617 -2.804,6.77 -2.804,15.214 0,21.984 0.292,0.704 0.913,1.325 1.617,1.617 6.77,2.804 15.214,2.804 21.984,0 0.704,-0.292 1.325,-0.913 1.617,-1.617 2.804,-6.77 2.804,-15.214 0,-21.984 -0.292,-0.704 -0.913,-1.325 -1.617,-1.617 -3.385,-1.402 -7.188,-2.103 -10.992,-2.103zM20.15,5.003c1.807,3.129 3.612,6.258 5.419,9.387l-10.839,0c1.806,-3.129 3.613,-6.258 5.419,-9.387zM5.277,5.652l8.719,0l0,8.719L5.277,14.371ZM6.678,7.052l0,5.918l5.918,0L12.596,7.052ZM20.15,7.869c-0.979,1.696 -1.959,3.392 -2.938,5.088l5.876,0c-0.979,-1.696 -1.959,-3.392 -2.938,-5.088zM20.14,14.938c0.039,-0 0.077,0 0.116,0.001 0.364,-0 0.727,0.041 1.082,0.123 2.378,0.501 4.09,3.008 3.664,5.406 -0.324,2.452 -2.777,4.359 -5.235,4.047 -2.465,-0.199 -4.49,-2.554 -4.305,-5.022 0.071,-2.433 2.252,-4.544 4.679,-4.555zM12.904,15.595c0.34,0.336 0.68,0.672 1.02,1.008 -1.085,1.098 -2.169,2.195 -3.254,3.293 1.097,1.085 2.194,2.169 3.291,3.254 -0.335,0.34 -0.671,0.68 -1.006,1.02l-3.293,-3.256 -3.256,3.293c-0.34,-0.336 -0.68,-0.672 -1.02,-1.008l3.256,-3.293c-1.098,-1.085 -2.195,-2.169 -3.293,-3.254 0.336,-0.34 0.672,-0.68 1.008,-1.02l3.293,3.254c1.085,-1.097 2.169,-2.194 3.254,-3.291zM20.347,16.447c-0.03,0 -0.061,0.001 -0.091,0.002 -1.242,-0.016 -2.443,0.735 -2.969,1.86 -0.891,1.722 0.112,4.06 1.987,4.576 1.838,0.634 4.008,-0.707 4.238,-2.642 0.356,-1.888 -1.252,-3.803 -3.164,-3.796z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeLineCap="square"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector android:height="24dp" android:viewportHeight="426.81"
|
||||
android:viewportWidth="426.81" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#000000"
|
||||
android:pathData="m73.19,122.41 l-14.23,14h-28.7,-4.43c-6.4,7.57 -10.74,14.82 -14.96,24.9 -12,28.5 -13.7,60.5 -4.5,88.3 5.4,16.2 20.4,39.7 32.2,50.3l5,4.5L196.27,304.4 348.87,304.4l9,-9 8.9,-9 17,-0.2 17,-0.3 3.8,-5c9.1,-11.9 17.3,-32 20.2,-49.4 5.55,-33.2 -4.23,-71.49 -23.84,-95.5L391.7,136l-9.93,0.2c-8.1,0.1 -15.3,0.1 -15.9,-0.2 -0.57,-0.19 -2.84,-2.48 -5.02,-5.08l-8.42,-8.52L213.47,122.41ZM87.37,133.4h124.5,124.5v72,72h-124.5,-124.5v-72zM381.24,162.24c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM36.78,167.09h16.94c0.75,0 1.35,0.61 1.35,1.35v15.7c0,0.87 -0.35,1.69 -0.98,2.29l-7.89,7.58c-0.55,0.52 -1.41,0.52 -1.96,0l-7.91,-7.58c-0.62,-0.6 -0.98,-1.43 -0.97,-2.29l0.05,-15.7c0,-0.75 0.61,-1.36 1.36,-1.36zM355.46,188.03c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM407.02,188.03c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM29.38,190.15c0.87,-0 1.69,0.35 2.29,0.97l7.58,7.91c0.52,0.55 0.52,1.41 -0,1.96l-7.58,7.89c-0.6,0.62 -1.43,0.98 -2.29,0.98h-15.7c-0.75,0 -1.35,-0.61 -1.35,-1.35v-16.94c0,-0.75 0.61,-1.36 1.35,-1.36zM61.25,190.33h15.7c0.75,0 1.35,0.61 1.35,1.35v16.94c0,0.75 -0.61,1.36 -1.36,1.36l-15.7,0.05c-0.87,0 -1.7,-0.35 -2.29,-0.97l-7.58,-7.91c-0.52,-0.55 -0.52,-1.41 0,-1.96l7.58,-7.89c0.6,-0.62 1.43,-0.98 2.3,-0.98zM45.39,205.76c0.35,-0 0.71,0.13 0.98,0.39l7.91,7.58c0.62,0.6 0.98,1.43 0.98,2.29l-0.05,15.7c-0,0.75 -0.61,1.36 -1.36,1.36h-16.94c-0.75,0 -1.35,-0.61 -1.35,-1.35v-15.7c0,-0.87 0.35,-1.69 0.98,-2.29l7.89,-7.58c0.27,-0.26 0.63,-0.39 0.98,-0.39zM381.24,213.81c6.86,0 12.42,5.56 12.42,12.42 0,6.86 -5.56,12.42 -12.42,12.42 -6.86,0 -12.42,-5.56 -12.42,-12.42 0,-6.86 5.56,-12.42 12.42,-12.42zM48.04,243.19a14.41,14.41 0,0 1,14.41 14.41,14.41 14.41,0 0,1 -14.41,14.41 14.41,14.41 0,0 1,-14.41 -14.41,14.41 14.41,0 0,1 6.47,-11.69c0.3,-0.22 0.61,-0.43 0.93,-0.62a14.41,14.41 0,0 1,2.57 -1.21c1.17,-0.41 2.39,-0.66 3.65,-0.73a14.41,14.41 0,0 1,0.8 -0.16zM36.97,289.26h25.36l11.85,10.16h-25.36l-0.01,-0.01h-2.03l-4.9,-5 -4.9,-5h0.17z"
|
||||
android:strokeColor="#00000000" android:strokeWidth="0.0999997"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="50"
|
||||
android:viewportHeight="50">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19.313,4C19.012,4 18.707,3.988 18.406,4.188C18.105,4.387 18,4.699 18,5L18,41.594C18,41.992 18.289,42.395 18.688,42.594L26.688,45L27,45C27.199,45 27.395,44.914 27.594,44.813C27.895,44.613 28,44.301 28,44L28,13.406C28.602,13.707 29,14.301 29,15L29,26.094C29,26.395 29.199,26.805 29.5,26.906C29.699,27.008 31.199,27.906 34,27.906C36.699,27.906 40,26.414 40,19.313C40,13.613 36.813,9.293 31.313,7.594ZM17,26.406L5.906,30.406L4.313,31C1.613,32.102 0,33.887 0,35.688C0,39.488 2.699,41.688 7.5,41.688C10.102,41.688 13.301,41.113 17,39.813L17,36C16.102,36.301 15.113,36.699 14.313,37C12.711,37.602 11.5,37.813 10.5,37.813C9,37.813 8.301,37.301 8,37C7.602,36.699 7.398,36.313 7.5,35.813C7.602,34.813 8.801,33.895 11,33.094C11.5,32.895 14.898,31.699 17,31ZM36.5,28.906C34.102,29.008 31.602,29.395 29,30.094L29,34.688C30.102,34.289 31.586,33.801 33.688,33C38.488,31.301 40.492,31.488 41.094,31.688C42.293,31.789 42.801,32.5 43,33C43.5,34.5 41.613,35.188 38.813,36.188C37.512,36.688 31.898,38.688 29,39.688L29,44.313L44.5,38.813L45.688,38.313C47.688,37.613 50.199,36.301 50,34C49.898,31.801 47.211,30.695 45.313,30.094C42.512,29.195 39.5,28.805 36.5,28.906Z"/>
|
||||
</vector>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
|
||||
<gradient
|
||||
android:startColor="#FF00A7FF"
|
||||
android:endColor="#FF93278F"
|
||||
android:angle="270"/>
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="67.733dp"
|
||||
android:height="67.733dp"
|
||||
android:viewportWidth="67.733"
|
||||
android:viewportHeight="67.733">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M56.541,21.775 L41.725,6.959c-0.403,-0.393 -0.945,-0.612 -1.508,-0.609L14.817,6.35c-2.338,0 -4.233,1.895 -4.233,4.233v23.283c-0,1.169 0.948,2.117 2.117,2.117h42.333c1.169,0 2.117,-0.948 2.117,-2.117v-10.583c0.003,-0.563 -0.215,-1.105 -0.609,-1.508zM40.217,23.283v-11.642l11.642,11.642zM24.342,44.45c0,1.169 -0.948,2.117 -2.117,2.117h-2.646v10.583c0,2.822 -4.233,2.822 -4.233,0v-10.583h-2.646c-2.818,-0.004 -2.818,-4.229 0,-4.233h9.525c1.169,0 2.117,0.948 2.117,2.117zM40.217,53.975c0,3.466 -2.461,5.556 -6.615,5.556 -1.921,-0.006 -3.788,-0.637 -5.318,-1.799v0c-0.501,-0.394 -0.786,-1.003 -0.767,-1.64 -0,-1.169 0.948,-2.117 2.117,-2.117 0.49,0.004 0.965,0.171 1.349,0.476h-0.026c0.781,0.533 1.7,0.827 2.646,0.847 2.381,0 2.381,-0.794 2.381,-1.323 0,-0.529 0,-0.238 -0.317,-0.423 -0.317,-0.185 -1.535,-0.582 -2.434,-0.82 -2.275,-0.609 -5.715,-1.535 -5.715,-5.106 0,-3.572 2.514,-5.556 6.085,-5.556 1.921,0.006 3.788,0.637 5.318,1.799v0c0.501,0.394 0.786,1.003 0.767,1.64 0,1.169 -0.948,2.117 -2.117,2.117 -0.49,-0.004 -0.965,-0.171 -1.349,-0.476h0.026c-0.781,-0.533 -1.7,-0.827 -2.646,-0.847 -1.217,0 -1.852,0.45 -1.852,1.323v0.053c0.291,0.344 1.746,0.741 2.566,0.952 2.196,0.582 5.9,1.561 5.9,5.345zM55.798,42.855c-1.081,-0.457 -2.328,0.054 -2.778,1.138l-3.334,8.017 -3.334,-8.017c-1.095,-2.606 -5.005,-0.968 -3.916,1.64l5.292,12.7c0.719,1.75 3.197,1.75 3.916,0l2.864,-6.874 2.427,-5.826c0.457,-1.081 -0.054,-2.328 -1.138,-2.778z"
|
||||
android:strokeWidth="0.264583"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<vector android:height="24dp" android:viewportHeight="115.43"
|
||||
android:viewportWidth="115.43" android:width="24dp"
|
||||
xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#000000" android:fillType="nonZero"
|
||||
android:pathData="m27.32,30.4c-15.13,0 -27.32,12.18 -27.32,27.32 0,15.13 12.18,27.32 27.32,27.32l60.79,0c15.13,0 27.32,-12.18 27.32,-27.32 0,-15.13 -12.18,-27.32 -27.32,-27.32zM24.62,38.44L90.61,38.44L90.61,76.22L24.62,76.22ZM10.54,44.52l4.03,0c0.18,0 0.32,0.14 0.32,0.32l0,3.74c0,0.21 -0.08,0.4 -0.23,0.55l-1.88,1.8c-0.13,0.12 -0.34,0.12 -0.47,0l-1.88,-1.8c-0.15,-0.14 -0.23,-0.34 -0.23,-0.55l0.01,-3.74c0,-0.18 0.15,-0.32 0.32,-0.32zM104.06,44.6c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM109.1,49.65c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM99.01,49.65c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM8.78,50c0.21,-0 0.4,0.08 0.55,0.23l1.81,1.88c0.12,0.13 0.12,0.34 -0,0.47l-1.8,1.88c-0.14,0.15 -0.34,0.23 -0.55,0.23l-3.74,0c-0.18,0 -0.32,-0.14 -0.32,-0.32l0,-4.03c0,-0.18 0.14,-0.32 0.32,-0.32zM16.37,50.05l3.74,0c0.18,0 0.32,0.14 0.32,0.32l0,4.03c0,0.18 -0.14,0.32 -0.32,0.32l-3.74,0.01c-0.21,0 -0.4,-0.08 -0.55,-0.23l-1.8,-1.88c-0.12,-0.13 -0.12,-0.34 0,-0.47l1.8,-1.88c0.14,-0.15 0.34,-0.23 0.55,-0.23zM12.59,53.72c0.08,-0 0.17,0.03 0.23,0.09l1.88,1.81c0.15,0.14 0.23,0.34 0.23,0.55l-0.01,3.74c-0,0.18 -0.15,0.32 -0.32,0.32l-4.03,0c-0.18,0 -0.32,-0.14 -0.32,-0.32l0,-3.74c0,-0.21 0.08,-0.4 0.23,-0.55l1.88,-1.8c0.06,-0.06 0.15,-0.09 0.23,-0.09zM104.06,54.69c1.54,0 2.78,1.25 2.78,2.78 0,1.54 -1.25,2.78 -2.78,2.78 -1.54,0 -2.78,-1.25 -2.78,-2.78 0,-1.54 1.25,-2.78 2.78,-2.78zM15.83,66.39a2.87,2.87 0,0 1,2.87 2.87,2.87 2.87,0 0,1 -2.87,2.87 2.87,2.87 0,0 1,-2.87 -2.87,2.87 2.87,0 0,1 2.87,-2.87zM99.39,66.41a2.87,2.87 89.99,0 1,2.87 2.87,2.87 2.87,89.99 0,1 -2.87,2.87 2.87,2.87 89.99,0 1,-2.87 -2.87,2.87 2.87,89.99 0,1 2.87,-2.87z"
|
||||
android:strokeColor="#00000000" android:strokeLineCap="square"
|
||||
android:strokeLineJoin="round" android:strokeWidth="1"/>
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,31 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false">
|
||||
|
||||
<include
|
||||
android:id="@+id/include"
|
||||
layout="@layout/appbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="56dp"
|
||||
android:background="@color/colorAmoled"
|
||||
android:backgroundTint="@color/colorAmoled"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/include"
|
||||
tools:context=".activities.MainActivity" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
This layout is used only on API 21 & 22 (Lollipop) because on these API levels,
|
||||
Android doesn't scale SVGs very well - we noticed the logo stretched across the
|
||||
screen and looked ugly.
|
||||
In SplashActivity, setContentView(R.layout.activity_splash) is called only for API 21 & 22.
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/splashRoot"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:srcCompat="@drawable/splash" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.appbar.AppBarLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/toolbar_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:descendantFocusability="afterDescendants"
|
||||
app:liftOnScroll="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
style="@style/Widget.MaterialComponents.Toolbar.Primary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/colorAmoled"
|
||||
android:theme="@style/NPSTheme.ActionBar"
|
||||
app:layout_scrollFlags="scroll|enterAlways|snap" />
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
tools:context=".fragments.mainactivity.MainActivityFragment">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/appsRecycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
|
||||
app:spanCount="2"
|
||||
tools:listitem="@layout/recycler_item_grid" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/tsvChooserButton"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_marginBottom="40dp"
|
||||
android:backgroundTint="@color/colorPrimary"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:src="@drawable/ic_add_24dp"
|
||||
app:borderWidth="2dp"
|
||||
app:fabCustomSize="52dp"
|
||||
app:layout_anchor="@+id/appsRecycler"
|
||||
app:layout_anchorGravity="bottom|center"
|
||||
app:maxImageSize="32dp" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,116 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="0dp">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="214dp"
|
||||
android:backgroundTint="@color/colorAmoled"
|
||||
app:cardBackgroundColor="@color/colorPrimary"
|
||||
app:cardElevation="4dp"
|
||||
app:cardUseCompatPadding="false"
|
||||
app:contentPadding="15dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView2"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_games_placeholder_24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:ellipsize="marquee"
|
||||
android:enabled="true"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
android:textColor="@color/colorOnPrimary"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imageView2"
|
||||
tools:text="App Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appRegion"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="@drawable/rect_light_blue"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:paddingRight="10dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:text="ASIA" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appMinFW"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="@drawable/rect_red"
|
||||
android:padding="4dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/appRegion"
|
||||
tools:text="3.69" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appLastDate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:padding="4dp"
|
||||
android:textColor="@color/colorOnSecondary"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
tools:text="2018-04-22" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -1,105 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="0dp">
|
||||
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:backgroundTint="@color/colorAmoled"
|
||||
app:cardBackgroundColor="@color/colorPrimary"
|
||||
app:cardElevation="4dp"
|
||||
app:cardUseCompatPadding="false"
|
||||
app:contentPadding="15dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="62dp"
|
||||
android:layout_height="56dp"
|
||||
android:contentDescription="app icon"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_games_placeholder_24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:ellipsize="marquee"
|
||||
android:focusable="auto"
|
||||
android:focusableInTouchMode="true"
|
||||
android:marqueeRepeatLimit="marquee_forever"
|
||||
android:scrollHorizontally="true"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="viewStart"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
android:textColor="@color/colorOnPrimary"
|
||||
android:textSize="18sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="App Title" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appRegion"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:background="@drawable/rect_light_blue"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:paddingRight="10dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@android:color/white"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/appMinFW"
|
||||
app:layout_constraintStart_toEndOf="@+id/imageView"
|
||||
tools:text="ASIA" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appMinFW"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/rect_red"
|
||||
android:padding="4dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@android:color/white"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@+id/appRegion"
|
||||
tools:text="3.69" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/appLastDate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="4dp"
|
||||
android:textColor="@color/colorOnSecondary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
tools:text="2020-02-12" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/prefers"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/prefs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,213 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/linear"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppError"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="60dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:gravity="center"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/colorAccent"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:text="error" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollView2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:fillViewport="true"
|
||||
app:layout_constraintBottom_toTopOf="@+id/singleAppError"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/linearLayout">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constrainedHeight="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="Game Title" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/singleAppDownloadButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="66dp"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:text="@string/download"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppTitle" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/singleAppCopyZRIFButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="66dp"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:text="@string/copy_zrif"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppDownloadButton" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppContentIdTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-black"
|
||||
android:text="@string/content_id"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppCopyZRIFButton" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppContentId"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppContentIdTitle"
|
||||
tools:text="Content ID" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppSizeTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-black"
|
||||
android:text="@string/size"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppContentId" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppFwVerTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-black"
|
||||
android:text="@string/minfw"
|
||||
android:textAlignment="center"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppSize" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppSize"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="0.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppSizeTitle"
|
||||
tools:text="Size" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/singleAppFwVer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="48dp"
|
||||
android:fontFamily="sans-serif-light"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/singleAppFwVerTitle"
|
||||
tools:text="MinFW" />
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/linearLayout"
|
||||
android:layout_width="410dp"
|
||||
android:layout_height="250dp"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/singleAppIcon"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="100dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginEnd="100dp"
|
||||
android:contentDescription="@string/single_app_icon"
|
||||
app:srcCompat="@drawable/ic_games_placeholder_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user