Some work
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -13,6 +14,9 @@ plugins {
|
|||||||
id("dagger.hilt.android.plugin")
|
id("dagger.hilt.android.plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val acraAuthLogin: String = gradleLocalProperties(rootDir).getProperty("authLogin") ?: "\"acra_login\""
|
||||||
|
val acraAuthPassword: String = gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "org.xtimms.tokusho"
|
namespace = "org.xtimms.tokusho"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
@@ -28,6 +32,10 @@ android {
|
|||||||
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||||
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"")
|
buildConfigField("String", "BUILD_TIME", "\"${getBuildTime()}\"")
|
||||||
|
|
||||||
|
buildConfigField("String", "ACRA_URI", "\"https://bugs.kotatsu.app/report\"")
|
||||||
|
buildConfigField("String", "ACRA_AUTH_LOGIN", acraAuthLogin)
|
||||||
|
buildConfigField("String", "ACRA_AUTH_PASSWORD", acraAuthPassword)
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -43,6 +51,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
@@ -93,6 +104,7 @@ dependencies {
|
|||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.6.1")
|
||||||
|
implementation("ch.acra:acra-http:5.9.7")
|
||||||
implementation("com.google.android.material:material:1.11.0")
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
|
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
|
||||||
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
|
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import com.tencent.mmkv.MMKV
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.acra.ReportField
|
||||||
|
import org.acra.config.httpSender
|
||||||
|
import org.acra.data.StringFormat
|
||||||
|
import org.acra.ktx.initAcra
|
||||||
|
import org.acra.sender.HttpSender
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.xtimms.tokusho.core.database.MangaDatabase
|
import org.xtimms.tokusho.core.database.MangaDatabase
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
import org.xtimms.tokusho.core.updates.Updater
|
import org.xtimms.tokusho.core.updates.Updater
|
||||||
import org.xtimms.tokusho.crash.CrashActivity
|
|
||||||
import org.xtimms.tokusho.crash.GlobalExceptionHandler
|
|
||||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
@@ -44,7 +48,30 @@ class App : Application() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
|
// GlobalExceptionHandler.initialize(applicationContext, CrashActivity::class.java)
|
||||||
|
if (AppSettings.isACRAEnabled()) {
|
||||||
|
initAcra {
|
||||||
|
buildConfigClass = BuildConfig::class.java
|
||||||
|
reportFormat = StringFormat.JSON
|
||||||
|
httpSender {
|
||||||
|
uri = BuildConfig.ACRA_URI
|
||||||
|
basicAuthLogin = BuildConfig.ACRA_AUTH_LOGIN
|
||||||
|
basicAuthPassword = BuildConfig.ACRA_AUTH_PASSWORD
|
||||||
|
httpMethod = HttpSender.Method.POST
|
||||||
|
}
|
||||||
|
reportContent = listOf(
|
||||||
|
ReportField.PACKAGE_NAME,
|
||||||
|
ReportField.INSTALLATION_ID,
|
||||||
|
ReportField.APP_VERSION_CODE,
|
||||||
|
ReportField.APP_VERSION_NAME,
|
||||||
|
ReportField.ANDROID_VERSION,
|
||||||
|
ReportField.PHONE_MODEL,
|
||||||
|
ReportField.STACK_TRACE,
|
||||||
|
ReportField.CRASH_CONFIGURATION,
|
||||||
|
ReportField.CUSTOM_DATA,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
override fun attachBaseContext(base: Context?) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
@@ -48,6 +49,7 @@ import kotlinx.coroutines.launch
|
|||||||
import org.xtimms.tokusho.core.Navigation
|
import org.xtimms.tokusho.core.Navigation
|
||||||
import org.xtimms.tokusho.core.components.BottomNavBar
|
import org.xtimms.tokusho.core.components.BottomNavBar
|
||||||
import org.xtimms.tokusho.core.components.TopAppBar
|
import org.xtimms.tokusho.core.components.TopAppBar
|
||||||
|
import org.xtimms.tokusho.core.logs.FileLogger
|
||||||
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
import org.xtimms.tokusho.ui.theme.TokushoTheme
|
||||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -59,9 +61,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var coil: ImageLoader
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
if (!isTaskRoot) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val windowSizeClass = calculateWindowSizeClass(this)
|
val windowSizeClass = calculateWindowSizeClass(this)
|
||||||
@@ -74,6 +85,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
) {
|
) {
|
||||||
MainView(
|
MainView(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
|
loggers = loggers,
|
||||||
isCompactScreen = isCompactScreen,
|
isCompactScreen = isCompactScreen,
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
@@ -112,6 +124,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainView(
|
fun MainView(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
|
loggers: Set<FileLogger>,
|
||||||
isCompactScreen: Boolean,
|
isCompactScreen: Boolean,
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
) {
|
) {
|
||||||
@@ -161,6 +174,7 @@ fun MainView(
|
|||||||
) {
|
) {
|
||||||
Navigation(
|
Navigation(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
|
loggers = loggers,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
isCompactScreen = false,
|
isCompactScreen = false,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
@@ -180,6 +194,7 @@ fun MainView(
|
|||||||
}
|
}
|
||||||
Navigation(
|
Navigation(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
|
loggers = loggers,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
isCompactScreen = true,
|
isCompactScreen = true,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.xtimms.tokusho.core.logs.FileLogger
|
||||||
import org.xtimms.tokusho.core.model.ShelfCategory
|
import org.xtimms.tokusho.core.model.ShelfCategory
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
import org.xtimms.tokusho.core.motion.materialSharedAxisXIn
|
||||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
||||||
@@ -62,6 +63,7 @@ fun PathInterpolator.toEasing(): Easing {
|
|||||||
@Composable
|
@Composable
|
||||||
fun Navigation(
|
fun Navigation(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
|
loggers: Set<FileLogger>,
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
isCompactScreen: Boolean,
|
isCompactScreen: Boolean,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
@@ -180,6 +182,7 @@ fun Navigation(
|
|||||||
|
|
||||||
composable(ADVANCED_DESTINATION) {
|
composable(ADVANCED_DESTINATION) {
|
||||||
AdvancedView(
|
AdvancedView(
|
||||||
|
loggers = loggers,
|
||||||
navigateBack = navigateBack,
|
navigateBack = navigateBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -220,13 +223,16 @@ fun Navigation(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
|
||||||
composable(
|
composable(
|
||||||
route = DETAILS_DESTINATION
|
route = DETAILS_DESTINATION,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
|
||||||
|
type = NavType.LongType
|
||||||
|
}
|
||||||
|
)
|
||||||
) { navEntry ->
|
) { navEntry ->
|
||||||
DetailsView(
|
DetailsView(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
mangaId = 0L,
|
|
||||||
navigateBack = navigateBack,
|
navigateBack = navigateBack,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import java.text.BreakIterator
|
||||||
|
|
||||||
|
data class CharState(val preview: String, val current: String)
|
||||||
|
|
||||||
|
// https://github.com/danilkinkin/buckwheat/blob/master/app/src/main/java/com/danilkinkin/buckwheat/base/AnimatedNumber.kt
|
||||||
|
@Composable
|
||||||
|
fun AnimatedNumber(
|
||||||
|
value: String = "",
|
||||||
|
style: TextStyle = MaterialTheme.typography.displayLarge,
|
||||||
|
) {
|
||||||
|
|
||||||
|
var previewsValue by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
var blocks by remember { mutableStateOf<List<CharState>>(emptyList()) }
|
||||||
|
|
||||||
|
DisposableEffect(value) {
|
||||||
|
var splittedValue = emptyList<String>().toMutableList()
|
||||||
|
val it = BreakIterator.getCharacterInstance()
|
||||||
|
it.setText(value)
|
||||||
|
var count = 0
|
||||||
|
|
||||||
|
var start = 0
|
||||||
|
var end = it.next()
|
||||||
|
while (end != BreakIterator.DONE) {
|
||||||
|
splittedValue.add(value.substring(start, end))
|
||||||
|
|
||||||
|
start = end
|
||||||
|
end = it.next()
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
val length = splittedValue.size.coerceAtLeast(previewsValue.size)
|
||||||
|
|
||||||
|
var newBlocks: MutableList<CharState> = emptyList<CharState>().toMutableList()
|
||||||
|
|
||||||
|
for (i in 0..length) {
|
||||||
|
newBlocks.add(
|
||||||
|
CharState(
|
||||||
|
preview = previewsValue.getOrElse(previewsValue.size - i) { "" },
|
||||||
|
current = splittedValue.getOrElse(splittedValue.size - i) { "" },
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
newBlocks = newBlocks.asReversed()
|
||||||
|
|
||||||
|
blocks = newBlocks
|
||||||
|
previewsValue = splittedValue
|
||||||
|
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
blocks.forEach {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = it.current,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState > initialState) {
|
||||||
|
(slideInVertically(tween(durationMillis = 300)) { height -> height } + fadeIn(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
)).togetherWith(
|
||||||
|
slideOutVertically(tween(durationMillis = 300)) { height -> -height } + fadeOut(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
(slideInVertically(tween(durationMillis = 300)) { height -> -height } + fadeIn(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
)).togetherWith(
|
||||||
|
slideOutVertically(tween(durationMillis = 300)) { height -> height } + fadeOut(
|
||||||
|
tween(durationMillis = 300)
|
||||||
|
))
|
||||||
|
}.using(
|
||||||
|
SizeTransform(clip = false)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = ""
|
||||||
|
) { targetCount ->
|
||||||
|
Text(text = targetCount, style = style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
package org.xtimms.tokusho.core.components
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -27,6 +35,7 @@ import androidx.compose.material.icons.outlined.Update
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
@@ -186,14 +195,28 @@ internal fun PreferenceItemDescription(
|
|||||||
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
overflow: TextOverflow = TextOverflow.Ellipsis
|
overflow: TextOverflow = TextOverflow.Ellipsis
|
||||||
) {
|
) {
|
||||||
Text(
|
AnimatedContent(
|
||||||
|
targetState = text,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState > initialState) {
|
||||||
|
(slideInVertically { height -> -height } + fadeIn()).togetherWith(
|
||||||
|
slideOutVertically { height -> height } + fadeOut())
|
||||||
|
} else {
|
||||||
|
(slideInVertically { height -> height } + fadeIn()).togetherWith(
|
||||||
|
slideOutVertically { height -> -height } + fadeOut())
|
||||||
|
}.using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
modifier = modifier.padding(top = 2.dp),
|
modifier = modifier.padding(top = 2.dp),
|
||||||
text = text,
|
label = "Preference desc"
|
||||||
maxLines = maxLines,
|
) { targetText ->
|
||||||
style = style,
|
Text(
|
||||||
color = color.applyOpacity(enabled),
|
text = targetText,
|
||||||
overflow = overflow
|
maxLines = maxLines,
|
||||||
)
|
style = style,
|
||||||
|
color = color.applyOpacity(enabled),
|
||||||
|
overflow = overflow
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -552,9 +575,16 @@ fun PreferencesHintCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PreferenceStorageHeader(
|
fun PreferenceStorageHeader(
|
||||||
used: Long = 4L,
|
used: Float = 40F,
|
||||||
total: Long = 128L
|
total: Float = 128F
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val animatedProgress = animateFloatAsState(
|
||||||
|
targetValue = 1 - ((total - used) / total),
|
||||||
|
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||||
|
label = "Progress"
|
||||||
|
).value
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -562,27 +592,47 @@ fun PreferenceStorageHeader(
|
|||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
AnimatedNumber(
|
||||||
text = FileSize.BYTES.formatWithoutUnits(used),
|
value = FileSize.BYTES.formatWithoutUnits(used)
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
|
||||||
style = MaterialTheme.typography.displayLarge
|
|
||||||
)
|
)
|
||||||
Text(
|
AnimatedContent(
|
||||||
text = FileSize.BYTES.showUnit(LocalContext.current, used),
|
targetState = used,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState > initialState) {
|
||||||
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
|
} else {
|
||||||
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
|
}.using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.align(Alignment.Bottom)
|
.align(Alignment.Bottom)
|
||||||
.padding(PaddingValues(bottom = 8.dp))
|
.padding(PaddingValues(start = 4.dp, bottom = 8.dp)),
|
||||||
)
|
label = "Unit"
|
||||||
Text(
|
) { targetUsed ->
|
||||||
text = FileSize.BYTES.totalFormat(LocalContext.current, total),
|
Text(text = FileSize.BYTES.showUnit(LocalContext.current, targetUsed))
|
||||||
|
}
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = total,
|
||||||
|
transitionSpec = {
|
||||||
|
if (targetState > initialState) {
|
||||||
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
|
} else {
|
||||||
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
|
}.using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.Bottom)
|
.align(Alignment.Bottom)
|
||||||
.padding(PaddingValues(bottom = 8.dp))
|
.padding(PaddingValues(bottom = 8.dp)),
|
||||||
)
|
label = "Total used"
|
||||||
|
) { targetTotal ->
|
||||||
|
Text(
|
||||||
|
text = FileSize.BYTES.totalFormat(LocalContext.current, targetTotal),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { (1 - ((total - used) / total.toFloat())) },
|
progress = { animatedProgress },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
|
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
|
||||||
.height(16.dp)
|
.height(16.dp)
|
||||||
@@ -598,13 +648,20 @@ fun PreferenceStorageHeader(
|
|||||||
@Composable
|
@Composable
|
||||||
fun PreferenceStorageItem(
|
fun PreferenceStorageItem(
|
||||||
title: String,
|
title: String,
|
||||||
used: Long? = 0L,
|
used: Float = 0F,
|
||||||
total: Long?,
|
total: Float = 0F,
|
||||||
icon: Any? = null,
|
icon: Any? = null,
|
||||||
leadingIcon: (@Composable () -> Unit)? = null,
|
leadingIcon: (@Composable () -> Unit)? = null,
|
||||||
trailingIcon: (@Composable () -> Unit)? = null,
|
trailingIcon: (@Composable () -> Unit)? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val animatedProgress = animateFloatAsState(
|
||||||
|
targetValue = 1 - ((total - used) / total),
|
||||||
|
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||||
|
label = "Progress"
|
||||||
|
).value
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.combinedClickable(
|
modifier = Modifier.combinedClickable(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@@ -658,20 +715,30 @@ fun PreferenceStorageItem(
|
|||||||
text = title,
|
text = title,
|
||||||
enabled = true
|
enabled = true
|
||||||
)
|
)
|
||||||
Text(text = FileSize.BYTES.format(LocalContext.current, used ?: 0L))
|
AnimatedContent(
|
||||||
}
|
targetState = used,
|
||||||
if (total != null) {
|
transitionSpec = {
|
||||||
LinearProgressIndicator(
|
if (targetState > initialState) {
|
||||||
progress = { (1 - ((total - used!!) / total.toFloat())) },
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
modifier = Modifier
|
} else {
|
||||||
.padding(PaddingValues(top = 12.dp))
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
.height(5.dp)
|
}.using(SizeTransform(clip = false))
|
||||||
.fillMaxWidth(),
|
},
|
||||||
color = MaterialTheme.colorScheme.primary,
|
label = "Total used"
|
||||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
) { targetTotal ->
|
||||||
strokeCap = StrokeCap.Round,
|
Text(text = FileSize.BYTES.format(LocalContext.current, targetTotal))
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { animatedProgress },
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(PaddingValues(top = 12.dp))
|
||||||
|
.height(5.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
strokeCap = StrokeCap.Round,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
trailingIcon?.let {
|
trailingIcon?.let {
|
||||||
VerticalDivider(
|
VerticalDivider(
|
||||||
@@ -700,7 +767,7 @@ fun PreferenceStorageHeaderPreview() {
|
|||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
fun PreferenceStorageItemPreview() {
|
fun PreferenceStorageItemPreview() {
|
||||||
TokushoTheme {
|
TokushoTheme {
|
||||||
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0L)
|
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0F)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package org.xtimms.tokusho.core.components
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.only
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.systemBars
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -29,7 +31,8 @@ fun ScaffoldWithTopAppBar(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
|
||||||
|
.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
|
||||||
topBar = {
|
topBar = {
|
||||||
DefaultTopAppBar(
|
DefaultTopAppBar(
|
||||||
title = title,
|
title = title,
|
||||||
@@ -38,8 +41,7 @@ fun ScaffoldWithTopAppBar(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = floatingActionButton,
|
floatingActionButton = floatingActionButton,
|
||||||
contentWindowInsets = WindowInsets.systemBars
|
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
|
||||||
.only(WindowInsetsSides.Horizontal),
|
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package org.xtimms.tokusho.core.components
|
package org.xtimms.tokusho.core.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -60,12 +65,24 @@ fun SettingItem(title: String, description: String, icon: ImageVector?, onClick:
|
|||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
AnimatedContent(
|
||||||
text = description,
|
targetState = description,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
transitionSpec = {
|
||||||
maxLines = 1,
|
if (targetState > initialState) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
)
|
} else {
|
||||||
|
(fadeIn()).togetherWith(fadeOut())
|
||||||
|
}.using(SizeTransform(clip = false))
|
||||||
|
},
|
||||||
|
label = "Total used"
|
||||||
|
) { targetDescription ->
|
||||||
|
Text(
|
||||||
|
text = targetDescription,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ package org.xtimms.tokusho.core.database
|
|||||||
const val TABLE_MANGA = "manga"
|
const val TABLE_MANGA = "manga"
|
||||||
const val TABLE_TAGS = "tags"
|
const val TABLE_TAGS = "tags"
|
||||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||||
const val TABLE_SOURCES = "sources"
|
const val TABLE_SOURCES = "sources"
|
||||||
|
const val TABLE_HISTORY = "history"
|
||||||
@@ -2,14 +2,23 @@ package org.xtimms.tokusho.core.database
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
|
import androidx.room.InvalidationTracker
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.xtimms.tokusho.core.database.dao.HistoryDao
|
||||||
import org.xtimms.tokusho.core.database.dao.MangaDao
|
import org.xtimms.tokusho.core.database.dao.MangaDao
|
||||||
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
|
import org.xtimms.tokusho.core.database.dao.MangaSourcesDao
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||||
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||||
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
|
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
|
||||||
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
|
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
|
||||||
import org.xtimms.tokusho.core.database.entity.TagEntity
|
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||||
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
|
|
||||||
const val DATABASE_VERSION = 1
|
const val DATABASE_VERSION = 1
|
||||||
|
|
||||||
@@ -18,12 +27,15 @@ const val DATABASE_VERSION = 1
|
|||||||
MangaEntity::class,
|
MangaEntity::class,
|
||||||
TagEntity::class,
|
TagEntity::class,
|
||||||
MangaTagsEntity::class,
|
MangaTagsEntity::class,
|
||||||
MangaSourceEntity::class
|
MangaSourceEntity::class,
|
||||||
|
HistoryEntity::class
|
||||||
],
|
],
|
||||||
version = DATABASE_VERSION
|
version = DATABASE_VERSION
|
||||||
)
|
)
|
||||||
abstract class MangaDatabase : RoomDatabase() {
|
abstract class MangaDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun getHistoryDao(): HistoryDao
|
||||||
|
|
||||||
abstract fun getMangaDao(): MangaDao
|
abstract fun getMangaDao(): MangaDao
|
||||||
|
|
||||||
abstract fun getSourcesDao(): MangaSourcesDao
|
abstract fun getSourcesDao(): MangaSourcesDao
|
||||||
@@ -32,4 +44,14 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
.databaseBuilder(context, MangaDatabase::class.java, "tokusho-db")
|
.databaseBuilder(context, MangaDatabase::class.java, "tokusho-db")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
|
||||||
|
val scope = processLifecycleScope
|
||||||
|
if (scope.isActive) {
|
||||||
|
processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
|
||||||
|
removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.RawQuery
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.intellij.lang.annotations.Language
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryWithManga
|
||||||
|
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||||
|
import org.xtimms.tokusho.core.model.ListSortOrder
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class HistoryDao {
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
|
||||||
|
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
|
||||||
|
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
|
||||||
|
abstract fun observeAll(): Flow<List<HistoryWithManga>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||||
|
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
||||||
|
|
||||||
|
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
||||||
|
val orderBy = when (order) {
|
||||||
|
ListSortOrder.NEWEST -> "history.created_at DESC"
|
||||||
|
ListSortOrder.PROGRESS -> "history.percent DESC"
|
||||||
|
ListSortOrder.ALPHABETIC -> "manga.title"
|
||||||
|
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Language("RoomSql")
|
||||||
|
val query = SimpleSQLiteQuery(
|
||||||
|
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||||
|
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy",
|
||||||
|
)
|
||||||
|
return observeAllImpl(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
|
||||||
|
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""SELECT tags.* FROM tags
|
||||||
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
|
INNER JOIN history ON history.manga_id = manga_tags.manga_id
|
||||||
|
WHERE history.deleted_at = 0
|
||||||
|
GROUP BY manga_tags.tag_id
|
||||||
|
ORDER BY COUNT(manga_tags.manga_id) DESC
|
||||||
|
LIMIT :limit""",
|
||||||
|
)
|
||||||
|
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||||
|
abstract suspend fun find(id: Long): HistoryEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||||
|
abstract fun observe(id: Long): Flow<HistoryEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
|
||||||
|
abstract fun observeCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||||
|
abstract suspend fun findProgress(id: Long): Float?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract suspend fun insert(entity: HistoryEntity): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"UPDATE history SET page = :page, chapter_id = :chapterId, scroll = :scroll, percent = :percent, updated_at = :updatedAt, deleted_at = 0 WHERE manga_id = :mangaId",
|
||||||
|
)
|
||||||
|
abstract suspend fun update(
|
||||||
|
mangaId: Long,
|
||||||
|
page: Int,
|
||||||
|
chapterId: Long,
|
||||||
|
scroll: Float,
|
||||||
|
percent: Float,
|
||||||
|
updatedAt: Long,
|
||||||
|
): Int
|
||||||
|
|
||||||
|
suspend fun delete(mangaId: Long) = setDeletedAt(mangaId, System.currentTimeMillis())
|
||||||
|
|
||||||
|
suspend fun recover(mangaId: Long) = setDeletedAt(mangaId, 0L)
|
||||||
|
|
||||||
|
@Query("DELETE FROM history WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
|
||||||
|
abstract suspend fun gc(maxDeletionTime: Long)
|
||||||
|
|
||||||
|
suspend fun deleteAfter(minDate: Long) = setDeletedAtAfter(minDate, System.currentTimeMillis())
|
||||||
|
|
||||||
|
suspend fun clear() = setDeletedAtAfter(0L, System.currentTimeMillis())
|
||||||
|
|
||||||
|
suspend fun update(entity: HistoryEntity) = update(
|
||||||
|
mangaId = entity.mangaId,
|
||||||
|
page = entity.page,
|
||||||
|
chapterId = entity.chapterId,
|
||||||
|
scroll = entity.scroll,
|
||||||
|
percent = entity.percent,
|
||||||
|
updatedAt = entity.updatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun upsert(entity: HistoryEntity): Boolean {
|
||||||
|
return if (update(entity) == 0) {
|
||||||
|
insert(entity)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open suspend fun upsert(entities: Iterable<HistoryEntity>) {
|
||||||
|
for (e in entities) {
|
||||||
|
if (update(e) == 0) {
|
||||||
|
insert(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE history SET deleted_at = :deletedAt WHERE manga_id = :mangaId")
|
||||||
|
protected abstract suspend fun setDeletedAt(mangaId: Long, deletedAt: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE history SET deleted_at = :deletedAt WHERE created_at >= :minDate AND deleted_at = 0")
|
||||||
|
protected abstract suspend fun setDeletedAtAfter(minDate: Long, deletedAt: Long)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@RawQuery(observedEntities = [HistoryEntity::class])
|
||||||
|
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
|||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
import org.xtimms.tokusho.core.model.MangaSource
|
import org.xtimms.tokusho.core.model.MangaSource
|
||||||
import org.xtimms.tokusho.utils.lang.longHashCode
|
import org.xtimms.tokusho.utils.lang.longHashCode
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
// Entity to model
|
// Entity to model
|
||||||
|
|
||||||
@@ -73,4 +75,13 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
|||||||
|
|
||||||
fun MangaState(name: String): MangaState? = runCatching {
|
fun MangaState(name: String): MangaState? = runCatching {
|
||||||
MangaState.valueOf(name)
|
MangaState.valueOf(name)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
|
fun HistoryEntity.toMangaHistory() = MangaHistory(
|
||||||
|
createdAt = Instant.ofEpochMilli(createdAt),
|
||||||
|
updatedAt = Instant.ofEpochMilli(updatedAt),
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll.toInt(),
|
||||||
|
percent = percent,
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import org.xtimms.tokusho.core.database.TABLE_HISTORY
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = TABLE_HISTORY,
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = MangaEntity::class,
|
||||||
|
parentColumns = ["manga_id"],
|
||||||
|
childColumns = ["manga_id"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class HistoryEntity(
|
||||||
|
@PrimaryKey(autoGenerate = false)
|
||||||
|
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||||
|
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||||
|
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||||
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
|
@ColumnInfo(name = "page") val page: Int,
|
||||||
|
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||||
|
@ColumnInfo(name = "percent") val percent: Float,
|
||||||
|
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.xtimms.tokusho.core.database.entity
|
||||||
|
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import androidx.room.Junction
|
||||||
|
import androidx.room.Relation
|
||||||
|
|
||||||
|
class HistoryWithManga(
|
||||||
|
@Embedded val history: HistoryEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "manga_id"
|
||||||
|
)
|
||||||
|
val manga: MangaEntity,
|
||||||
|
@Relation(
|
||||||
|
parentColumn = "manga_id",
|
||||||
|
entityColumn = "tag_id",
|
||||||
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
|
)
|
||||||
|
val tags: List<TagEntity>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.xtimms.tokusho.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
|
||||||
|
class TooManyRequestExceptions(
|
||||||
|
val url: String,
|
||||||
|
val retryAt: Instant?,
|
||||||
|
) : IOException() {
|
||||||
|
val retryAfter: Long
|
||||||
|
get() = retryAt?.until(Instant.now(), ChronoUnit.MILLIS) ?: 0
|
||||||
|
}
|
||||||
147
app/src/main/java/org/xtimms/tokusho/core/logs/FileLogger.kt
Normal file
147
app/src/main/java/org/xtimms/tokusho/core/logs/FileLogger.kt
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package org.xtimms.tokusho.core.logs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||||
|
import org.xtimms.tokusho.utils.system.subdir
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
|
||||||
|
private const val DIR = "logs"
|
||||||
|
private const val FLUSH_DELAY = 2_000L
|
||||||
|
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||||
|
|
||||||
|
class FileLogger(
|
||||||
|
context: Context,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val file by lazy {
|
||||||
|
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||||
|
File(dir, "$name.log")
|
||||||
|
}
|
||||||
|
val isEnabled: Boolean
|
||||||
|
get() = AppSettings.isLoggingEnabled()
|
||||||
|
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(
|
||||||
|
Locale.ROOT)
|
||||||
|
private val buffer = ConcurrentLinkedQueue<String>()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var flushJob: Job? = null
|
||||||
|
|
||||||
|
fun log(message: String, e: Throwable? = null) {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val text = buildString {
|
||||||
|
append(dateTimeFormatter.format(LocalDateTime.now()))
|
||||||
|
append(": ")
|
||||||
|
if (e != null) {
|
||||||
|
append("E!")
|
||||||
|
}
|
||||||
|
append(message)
|
||||||
|
if (e != null) {
|
||||||
|
append(' ')
|
||||||
|
append(e.stackTraceToString())
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.add(text)
|
||||||
|
postFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
inline fun log(messageProducer: () -> String) {
|
||||||
|
if (isEnabled) {
|
||||||
|
log(messageProducer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun flush() {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushJob?.cancelAndJoin()
|
||||||
|
flushImpl()
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun flushBlocking() {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runBlockingSafe { flushJob?.cancelAndJoin() }
|
||||||
|
runBlockingSafe { flushImpl() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postFlush() {
|
||||||
|
if (flushJob?.isActive == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
delay(FLUSH_DELAY)
|
||||||
|
runCatchingCancellable {
|
||||||
|
flushImpl()
|
||||||
|
}.onFailure {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun flushImpl() = withContext(NonCancellable) {
|
||||||
|
mutex.withLock {
|
||||||
|
if (buffer.isEmpty()) {
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
if (file.length() > MAX_SIZE_BYTES) {
|
||||||
|
rotate()
|
||||||
|
}
|
||||||
|
FileOutputStream(file, true).use {
|
||||||
|
while (true) {
|
||||||
|
val message = buffer.poll() ?: break
|
||||||
|
it.write(message.toByteArray())
|
||||||
|
it.write('\n'.code)
|
||||||
|
}
|
||||||
|
it.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun rotate() {
|
||||||
|
val length = file.length()
|
||||||
|
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||||
|
file.renameTo(bakFile)
|
||||||
|
bakFile.inputStream().use { input ->
|
||||||
|
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||||
|
file.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bakFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
|
||||||
|
runBlocking(NonCancellable) { block() }
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/src/main/java/org/xtimms/tokusho/core/logs/Loggers.kt
Normal file
11
app/src/main/java/org/xtimms/tokusho/core/logs/Loggers.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package org.xtimms.tokusho.core.logs
|
||||||
|
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class TrackerLogger
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class SyncLogger
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.xtimms.tokusho.core.logs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.collection.arraySetOf
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.multibindings.ElementsIntoSet
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object LoggersModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@TrackerLogger
|
||||||
|
fun provideTrackerLogger(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) = FileLogger(context, "tracker")
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@SyncLogger
|
||||||
|
fun provideSyncLogger(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) = FileLogger(context, "sync")
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@ElementsIntoSet
|
||||||
|
fun provideAllLoggers(
|
||||||
|
@TrackerLogger trackerLogger: FileLogger,
|
||||||
|
@SyncLogger syncLogger: FileLogger,
|
||||||
|
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||||
|
trackerLogger,
|
||||||
|
syncLogger,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.xtimms.tokusho.core.model
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
|
||||||
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
fun Collection<Manga>.distinctById() = distinctBy { it.id }
|
||||||
|
|
||||||
|
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.xtimms.tokusho.core.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class MangaHistory(
|
||||||
|
val createdAt: Instant,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
val chapterId: Long,
|
||||||
|
val page: Int,
|
||||||
|
val scroll: Int,
|
||||||
|
val percent: Float,
|
||||||
|
) : Parcelable
|
||||||
@@ -3,9 +3,9 @@ package org.xtimms.tokusho.core.motion
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
public object MotionConstants {
|
object MotionConstants {
|
||||||
public const val DefaultMotionDuration: Int = 300
|
const val DefaultMotionDuration: Int = 300
|
||||||
public const val DefaultFadeInDuration: Int = 150
|
const val DefaultFadeInDuration: Int = 150
|
||||||
public const val DefaultFadeOutDuration: Int = 75
|
const val DefaultFadeOutDuration: Int = 75
|
||||||
public val DefaultSlideDistance: Dp = 30.dp
|
val DefaultSlideDistance: Dp = 30.dp
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.xtimms.tokusho.core.network
|
||||||
|
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
|
||||||
|
object CommonHeaders {
|
||||||
|
|
||||||
|
const val REFERER = "Referer"
|
||||||
|
const val USER_AGENT = "User-Agent"
|
||||||
|
const val ACCEPT = "Accept"
|
||||||
|
const val CONTENT_TYPE = "Content-Type"
|
||||||
|
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||||
|
const val COOKIE = "Cookie"
|
||||||
|
const val CONTENT_ENCODING = "Content-Encoding"
|
||||||
|
const val ACCEPT_ENCODING = "Accept-Encoding"
|
||||||
|
const val AUTHORIZATION = "Authorization"
|
||||||
|
const val CACHE_CONTROL = "Cache-Control"
|
||||||
|
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
||||||
|
const val RETRY_AFTER = "Retry-After"
|
||||||
|
|
||||||
|
val CACHE_CONTROL_NO_STORE: CacheControl
|
||||||
|
get() = CacheControl.Builder().noStore().build()
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import okhttp3.OkHttpClient
|
|||||||
import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar
|
import org.xtimms.tokusho.core.network.cookies.AndroidCookieJar
|
||||||
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
|
import org.xtimms.tokusho.core.network.cookies.MutableCookieJar
|
||||||
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar
|
import org.xtimms.tokusho.core.network.cookies.PreferencesCookieJar
|
||||||
|
import org.xtimms.tokusho.core.network.interceptors.CacheLimitInterceptor
|
||||||
|
import org.xtimms.tokusho.core.network.interceptors.CommonHeadersInterceptor
|
||||||
|
import org.xtimms.tokusho.core.network.interceptors.GZipInterceptor
|
||||||
|
import org.xtimms.tokusho.core.network.interceptors.RateLimitInterceptor
|
||||||
import org.xtimms.tokusho.data.LocalStorageManager
|
import org.xtimms.tokusho.data.LocalStorageManager
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -55,6 +59,8 @@ interface NetworkModule {
|
|||||||
writeTimeout(20, TimeUnit.SECONDS)
|
writeTimeout(20, TimeUnit.SECONDS)
|
||||||
cookieJar(cookieJar)
|
cookieJar(cookieJar)
|
||||||
cache(cache)
|
cache(cache)
|
||||||
|
addInterceptor(GZipInterceptor())
|
||||||
|
addInterceptor(RateLimitInterceptor())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@@ -62,7 +68,11 @@ interface NetworkModule {
|
|||||||
@MangaHttpClient
|
@MangaHttpClient
|
||||||
fun provideMangaHttpClient(
|
fun provideMangaHttpClient(
|
||||||
@BaseHttpClient baseClient: OkHttpClient,
|
@BaseHttpClient baseClient: OkHttpClient,
|
||||||
): OkHttpClient = baseClient.newBuilder().build()
|
commonHeadersInterceptor: CommonHeadersInterceptor,
|
||||||
|
): OkHttpClient = baseClient.newBuilder().apply {
|
||||||
|
addNetworkInterceptor(CacheLimitInterceptor())
|
||||||
|
addInterceptor(commonHeadersInterceptor)
|
||||||
|
}.build()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class CacheLimitInterceptor : Interceptor {
|
||||||
|
|
||||||
|
private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1)
|
||||||
|
private val defaultCacheControl = CacheControl.Builder()
|
||||||
|
.maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
val responseCacheControl = CacheControl.parse(response.headers)
|
||||||
|
if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
return response.newBuilder()
|
||||||
|
.header(CommonHeaders.CACHE_CONTROL, defaultCacheControl)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.Lazy
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
|
import org.xtimms.tokusho.BuildConfig
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||||
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
|
import org.xtimms.tokusho.core.parser.RemoteMangaRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CommonHeadersInterceptor @Inject constructor(
|
||||||
|
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val source = request.tag(MangaSource::class.java)
|
||||||
|
val repository = if (source != null) {
|
||||||
|
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
|
||||||
|
} else {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.w("Http", "Request without source tag: ${request.url}")
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val headersBuilder = request.headers.newBuilder()
|
||||||
|
repository?.headers?.let {
|
||||||
|
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||||
|
}
|
||||||
|
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||||
|
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
|
||||||
|
}
|
||||||
|
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
||||||
|
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
||||||
|
set(name, value)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ProxyChain(
|
||||||
|
private val delegate: Interceptor.Chain,
|
||||||
|
private val request: Request,
|
||||||
|
) : Interceptor.Chain by delegate {
|
||||||
|
|
||||||
|
override fun request(): Request = request
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.IOException
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders.CONTENT_ENCODING
|
||||||
|
|
||||||
|
class GZipInterceptor : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val newRequest = chain.request().newBuilder()
|
||||||
|
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
||||||
|
return try {
|
||||||
|
chain.proceed(newRequest.build())
|
||||||
|
} catch (e: NullPointerException) {
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.xtimms.tokusho.core.network.interceptors
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.internal.closeQuietly
|
||||||
|
import org.xtimms.tokusho.core.exceptions.TooManyRequestExceptions
|
||||||
|
import org.xtimms.tokusho.core.network.CommonHeaders
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
class RateLimitInterceptor : Interceptor {
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val response = chain.proceed(chain.request())
|
||||||
|
if (response.code == 429) {
|
||||||
|
val retryDate = response.header(CommonHeaders.RETRY_AFTER)?.parseRetryDate()
|
||||||
|
val request = response.request
|
||||||
|
response.closeQuietly()
|
||||||
|
throw TooManyRequestExceptions(
|
||||||
|
url = request.url.toString(),
|
||||||
|
retryAt = retryDate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.parseRetryDate(): Instant? {
|
||||||
|
return toLongOrNull()?.let { Instant.now().plusSeconds(it) }
|
||||||
|
?: ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.MainCoroutineDispatcher
|
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import okhttp3.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
@@ -60,6 +61,9 @@ class RemoteMangaRepository(
|
|||||||
val domains: Array<out String>
|
val domains: Array<out String>
|
||||||
get() = parser.configKeyDomain.presetValues
|
get() = parser.configKeyDomain.presetValues
|
||||||
|
|
||||||
|
val headers: Headers
|
||||||
|
get() = parser.headers
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
return if (parser is Interceptor) {
|
return if (parser is Interceptor) {
|
||||||
parser.intercept(chain)
|
parser.intercept(chain)
|
||||||
|
|||||||
@@ -26,12 +26,16 @@ const val UPDATE_CHANNEL = "update_channel"
|
|||||||
private const val THEME_COLOR = "theme_color"
|
private const val THEME_COLOR = "theme_color"
|
||||||
const val PALETTE_STYLE = "palette_style"
|
const val PALETTE_STYLE = "palette_style"
|
||||||
const val LANGUAGE = "language"
|
const val LANGUAGE = "language"
|
||||||
|
const val READING_TIME = "reading_time"
|
||||||
|
|
||||||
const val SYSTEM_DEFAULT = 0
|
const val SYSTEM_DEFAULT = 0
|
||||||
|
|
||||||
const val STABLE = 0
|
const val STABLE = 0
|
||||||
const val PRE_RELEASE = 1
|
const val PRE_RELEASE = 1
|
||||||
|
|
||||||
|
const val ACRA = "acra"
|
||||||
|
const val LOGGING = "logging"
|
||||||
|
|
||||||
val paletteStyles = listOf(
|
val paletteStyles = listOf(
|
||||||
PaletteStyle.TonalSpot,
|
PaletteStyle.TonalSpot,
|
||||||
PaletteStyle.Spritz,
|
PaletteStyle.Spritz,
|
||||||
@@ -87,6 +91,12 @@ object AppSettings {
|
|||||||
|
|
||||||
fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(false)
|
fun isAutoUpdateEnabled() = AUTO_UPDATE.getBoolean(false)
|
||||||
|
|
||||||
|
fun isACRAEnabled() = ACRA.getBoolean(false)
|
||||||
|
|
||||||
|
fun isLoggingEnabled() = LOGGING.getBoolean(false)
|
||||||
|
|
||||||
|
fun isReadingTimeEstimationEnabled() = READING_TIME.getBoolean(true)
|
||||||
|
|
||||||
fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) =
|
fun getLanguageConfiguration(languageNumber: Int = kv.decodeInt(LANGUAGE)) =
|
||||||
languageMap.getOrElse(languageNumber) { "" }
|
languageMap.getOrElse(languageNumber) { "" }
|
||||||
|
|
||||||
@@ -112,13 +122,10 @@ object AppSettings {
|
|||||||
private val mutableAppSettingsStateFlow = MutableStateFlow(
|
private val mutableAppSettingsStateFlow = MutableStateFlow(
|
||||||
Settings(
|
Settings(
|
||||||
DarkThemePreference(
|
DarkThemePreference(
|
||||||
darkThemeValue = kv.decodeInt(
|
darkThemeValue = kv.decodeInt(DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM),
|
||||||
DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM
|
isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
|
||||||
), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
|
|
||||||
),
|
|
||||||
isDynamicColorEnabled = kv.decodeBool(
|
|
||||||
DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()
|
|
||||||
),
|
),
|
||||||
|
isDynamicColorEnabled = kv.decodeBool(DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()),
|
||||||
seedColor = kv.decodeInt(THEME_COLOR, SEED),
|
seedColor = kv.decodeInt(THEME_COLOR, SEED),
|
||||||
paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
|
paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ private const val DIR_NAME = "manga"
|
|||||||
private const val NOMEDIA = ".nomedia"
|
private const val NOMEDIA = ".nomedia"
|
||||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||||
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
private const val CACHE_SIZE_MIN: Long = 10 * 1024 * 1024 // 10MB
|
||||||
private const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
const val CACHE_SIZE_MAX: Long = 250 * 1024 * 1024 // 250MB
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
class LocalStorageManager @Inject constructor(
|
class LocalStorageManager @Inject constructor(
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package org.xtimms.tokusho.data.repository
|
||||||
|
|
||||||
|
import dagger.Reusable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.xtimms.tokusho.core.database.MangaDatabase
|
||||||
|
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||||
|
import org.xtimms.tokusho.core.database.entity.toMangaHistory
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
import org.xtimms.tokusho.core.model.findById
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
const val PROGRESS_NONE = -1f
|
||||||
|
|
||||||
|
@Reusable
|
||||||
|
class HistoryRepository @Inject constructor(
|
||||||
|
private val db: MangaDatabase,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun getOne(manga: Manga): MangaHistory? {
|
||||||
|
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeOne(id: Long): Flow<MangaHistory?> {
|
||||||
|
return db.getHistoryDao().observe(id).map {
|
||||||
|
it?.toMangaHistory()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
|
||||||
|
val chapters = manga.chapters
|
||||||
|
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
val newChapterId = chapters.getOrNull(
|
||||||
|
(chapters.size * percent).toInt(),
|
||||||
|
)?.id ?: return this
|
||||||
|
val newEntity = copy(chapterId = newChapterId)
|
||||||
|
db.getHistoryDao().update(newEntity)
|
||||||
|
return newEntity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
package org.xtimms.tokusho.sections.details
|
package org.xtimms.tokusho.sections.details
|
||||||
|
|
||||||
import coil.ImageLoader
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.xtimms.tokusho.core.base.state.UiState
|
import org.xtimms.tokusho.core.base.state.UiState
|
||||||
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
|
|
||||||
data class DetailsUiState(
|
data class DetailsUiState(
|
||||||
val manga: Manga? = null,
|
val details: MangaDetails? = null,
|
||||||
override val isLoading: Boolean = false,
|
override val isLoading: Boolean = false,
|
||||||
override val message: String? = null,
|
override val message: String? = null,
|
||||||
) : UiState() {
|
) : UiState() {
|
||||||
|
|||||||
@@ -7,39 +7,41 @@ import androidx.compose.foundation.layout.calculateStartPadding
|
|||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Timelapse
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.DetailsToolbar
|
import org.xtimms.tokusho.core.components.DetailsToolbar
|
||||||
|
import org.xtimms.tokusho.core.components.PreferenceItem
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
|
||||||
const val MANGA_ID_ARGUMENT = "{mangaId}"
|
const val MANGA_ID_ARGUMENT = "{mangaId}"
|
||||||
const val DETAILS_DESTINATION = "details/$MANGA_ID_ARGUMENT"
|
const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailsView(
|
fun DetailsView(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
mangaId: Long,
|
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
val viewModel: DetailsViewModel = hiltViewModel()
|
val viewModel: DetailsViewModel = hiltViewModel()
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val chapterListState = rememberLazyListState()
|
val chapterListState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(mangaId) {
|
|
||||||
viewModel.getDetails(mangaId)
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
val isFirstItemVisible by remember {
|
val isFirstItemVisible by remember {
|
||||||
@@ -57,7 +59,7 @@ fun DetailsView(
|
|||||||
label = "Top Bar Background",
|
label = "Top Bar Background",
|
||||||
)
|
)
|
||||||
DetailsToolbar(
|
DetailsToolbar(
|
||||||
title = "Test",
|
title = uiState.details?.toManga()?.title ?: "Unknown",
|
||||||
titleAlphaProvider = { animatedTitleAlpha },
|
titleAlphaProvider = { animatedTitleAlpha },
|
||||||
backgroundAlphaProvider = { animatedBgAlpha },
|
backgroundAlphaProvider = { animatedBgAlpha },
|
||||||
onBackClicked = { navigateBack() }
|
onBackClicked = { navigateBack() }
|
||||||
@@ -84,26 +86,41 @@ fun DetailsView(
|
|||||||
) {
|
) {
|
||||||
DetailsInfoBox(
|
DetailsInfoBox(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
imageUrl = uiState.manga?.largeCoverUrl ?: "",
|
imageUrl = uiState.details?.toManga()?.largeCoverUrl ?: "",
|
||||||
title = uiState.manga?.title ?: "",
|
title = uiState.details?.toManga()?.title ?: "",
|
||||||
author = uiState.manga?.author ?: "",
|
author = uiState.details?.toManga()?.author ?: "",
|
||||||
artist = "",
|
artist = "",
|
||||||
state = uiState.manga?.state ?: MangaState.FINISHED,
|
state = uiState.details?.toManga()?.state ?: MangaState.FINISHED,
|
||||||
isTabletUi = false,
|
isTabletUi = false,
|
||||||
appBarPadding = topPadding,
|
appBarPadding = topPadding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val time = viewModel.readingTime.value
|
||||||
|
if (AppSettings.isReadingTimeEstimationEnabled() || time == null) {
|
||||||
|
item {
|
||||||
|
if (time != null) {
|
||||||
|
PreferenceItem(
|
||||||
|
title = if (time.isContinue) stringResource(id = R.string.approximate_remaining_time) else stringResource(
|
||||||
|
id = R.string.approximate_reading_time
|
||||||
|
),
|
||||||
|
description = time.format(context.resources),
|
||||||
|
icon = Icons.Outlined.Timelapse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item(
|
item(
|
||||||
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
||||||
contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
||||||
) {
|
) {
|
||||||
ExpandableMangaDescription(
|
ExpandableMangaDescription(
|
||||||
defaultExpandState = true,
|
defaultExpandState = true,
|
||||||
description = uiState.manga?.description ?: "",
|
description = uiState.details?.toManga()?.description ?: "",
|
||||||
tagsProvider = { uiState.manga?.tags?.toList() },
|
tagsProvider = { uiState.details?.toManga()?.tags?.toList() },
|
||||||
onTagSearch = { },
|
onTagSearch = { },
|
||||||
onCopyTagToClipboard = { },
|
onCopyTagToClipboard = { },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import androidx.lifecycle.SavedStateHandle
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -14,42 +16,80 @@ import kotlinx.coroutines.plus
|
|||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
||||||
import org.xtimms.tokusho.core.parser.MangaIntent
|
import org.xtimms.tokusho.core.parser.MangaIntent
|
||||||
|
import org.xtimms.tokusho.data.repository.HistoryRepository
|
||||||
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
|
import org.xtimms.tokusho.sections.details.domain.DetailsLoadUseCase
|
||||||
|
import org.xtimms.tokusho.sections.details.domain.ReadingTimeUseCase
|
||||||
import org.xtimms.tokusho.utils.lang.onEachWhile
|
import org.xtimms.tokusho.utils.lang.onEachWhile
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DetailsViewModel @Inject constructor(
|
class DetailsViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val historyRepository: HistoryRepository,
|
||||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||||
|
private val readingTimeUseCase: ReadingTimeUseCase,
|
||||||
) : BaseViewModel<DetailsUiState>(), DetailsEvent {
|
) : BaseViewModel<DetailsUiState>(), DetailsEvent {
|
||||||
|
|
||||||
private val intent = MangaIntent(savedStateHandle)
|
|
||||||
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
|
|
||||||
|
|
||||||
val manga = details.map { x -> x?.toManga() }
|
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
|
||||||
|
|
||||||
override val mutableUiState = MutableStateFlow(DetailsUiState())
|
override val mutableUiState = MutableStateFlow(DetailsUiState())
|
||||||
|
|
||||||
fun getDetails(mangaId: Long) {
|
private val intent = MangaIntent(savedStateHandle)
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
private val mangaId = intent.id
|
||||||
detailsLoadUseCase.invoke(intent)
|
|
||||||
.onEachWhile {
|
private var loadingJob: Job
|
||||||
if (it.allChapters.isEmpty()) {
|
|
||||||
return@onEachWhile false
|
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
|
||||||
}
|
val mangaD = details.map { x -> x?.toManga() }
|
||||||
true
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
}.collect {
|
|
||||||
mutableUiState.update {
|
val history = historyRepository.observeOne(mangaId)
|
||||||
val manga = details.firstOrNull { it != null } ?: return@collect
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
it.copy(
|
|
||||||
manga = manga.toManga()
|
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||||
)
|
|
||||||
}
|
private val chaptersQuery = MutableStateFlow("")
|
||||||
}
|
val selectedBranch = MutableStateFlow<String?>(null)
|
||||||
}
|
|
||||||
|
@Deprecated("")
|
||||||
|
val description = details
|
||||||
|
.map { it?.description }
|
||||||
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
|
||||||
|
|
||||||
|
val isChaptersEmpty: StateFlow<Boolean> = details.map {
|
||||||
|
it != null && it.isLoaded && it.allChapters.isEmpty()
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
|
||||||
|
|
||||||
|
val readingTime = combine(
|
||||||
|
details,
|
||||||
|
selectedBranch,
|
||||||
|
history,
|
||||||
|
) { m, b, h ->
|
||||||
|
readingTimeUseCase.invoke(m, b, h)
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadingJob = doLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun reload() {
|
||||||
|
loadingJob.cancel()
|
||||||
|
loadingJob = doLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
detailsLoadUseCase.invoke(mangaId ?: 0L)
|
||||||
|
.onEachWhile {
|
||||||
|
if (it.allChapters.isEmpty()) {
|
||||||
|
return@onEachWhile false
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}.collect {
|
||||||
|
//details.value = it
|
||||||
|
mutableUiState.update {
|
||||||
|
it.copy(
|
||||||
|
details = details.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.xtimms.tokusho.sections.details.data
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
|
||||||
|
data class ReadingTime(
|
||||||
|
val minutes: Int,
|
||||||
|
val hours: Int,
|
||||||
|
val isContinue: Boolean,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun format(resources: Resources): String = when {
|
||||||
|
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
|
||||||
|
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
|
||||||
|
else -> resources.getString(
|
||||||
|
R.string.remaining_time_pattern,
|
||||||
|
resources.getQuantityString(R.plurals.hours, hours, hours),
|
||||||
|
resources.getQuantityString(R.plurals.minutes, minutes, minutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,16 +7,12 @@ import android.text.style.ForegroundColorSpan
|
|||||||
import androidx.core.text.getSpans
|
import androidx.core.text.getSpans
|
||||||
import androidx.core.text.parseAsHtml
|
import androidx.core.text.parseAsHtml
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.recoverNotNull
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.xtimms.tokusho.core.parser.MangaDataRepository
|
import org.xtimms.tokusho.core.parser.MangaDataRepository
|
||||||
import org.xtimms.tokusho.core.parser.MangaIntent
|
|
||||||
import org.xtimms.tokusho.core.parser.MangaRepository
|
import org.xtimms.tokusho.core.parser.MangaRepository
|
||||||
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
import org.xtimms.tokusho.utils.lang.sanitize
|
import org.xtimms.tokusho.utils.lang.sanitize
|
||||||
@@ -29,9 +25,9 @@ class DetailsLoadUseCase @Inject constructor(
|
|||||||
private val imageGetter: Html.ImageGetter,
|
private val imageGetter: Html.ImageGetter,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
|
operator fun invoke(mangaId: Long): Flow<MangaDetails> = channelFlow {
|
||||||
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
|
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
|
||||||
"Cannot resolve intent $intent"
|
"Cannot resolve id $mangaId"
|
||||||
}
|
}
|
||||||
send(MangaDetails(manga, null, false))
|
send(MangaDetails(manga, null, false))
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.xtimms.tokusho.sections.details.domain
|
||||||
|
|
||||||
|
import org.xtimms.tokusho.core.model.MangaHistory
|
||||||
|
import org.xtimms.tokusho.core.model.findById
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||||
|
import org.xtimms.tokusho.sections.details.data.ReadingTime
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class ReadingTimeUseCase @Inject constructor() {
|
||||||
|
|
||||||
|
fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
|
||||||
|
if (!AppSettings.isReadingTimeEstimationEnabled()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// FIXME MAXIMUM HARDCODE!!! To do calculation with user's page read speed and his favourites/history mangas average pages in chapter
|
||||||
|
val chapters = manga?.chapters?.get(branch)
|
||||||
|
if (chapters.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
|
||||||
|
// Impossible task, I guess. Good luck on this.
|
||||||
|
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
|
||||||
|
if (isOnHistoryBranch) {
|
||||||
|
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||||
|
}
|
||||||
|
if (averageTimeSec < 60) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ReadingTime(
|
||||||
|
minutes = (averageTimeSec / 60) % 60,
|
||||||
|
hours = averageTimeSec / 3600,
|
||||||
|
isContinue = isOnHistoryBranch,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.collapsable
|
|
||||||
import org.xtimms.tokusho.core.components.ExploreButton
|
import org.xtimms.tokusho.core.components.ExploreButton
|
||||||
import org.xtimms.tokusho.core.components.SourceItem
|
import org.xtimms.tokusho.core.components.SourceItem
|
||||||
import org.xtimms.tokusho.core.components.icons.Dice
|
import org.xtimms.tokusho.core.components.icons.Dice
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -49,7 +48,7 @@ fun MangaListView(
|
|||||||
val viewModel: MangaListViewModel = hiltViewModel()
|
val viewModel: MangaListViewModel = hiltViewModel()
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
MangaListView(
|
MangaListViewContent(
|
||||||
coil = coil,
|
coil = coil,
|
||||||
source = source,
|
source = source,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
@@ -60,7 +59,7 @@ fun MangaListView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MangaListView(
|
private fun MangaListViewContent(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
uiState: MangaListUiState,
|
uiState: MangaListUiState,
|
||||||
@@ -69,7 +68,6 @@ private fun MangaListView(
|
|||||||
navigateToDetails: (Long) -> Unit,
|
navigateToDetails: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
if (uiState.message != null) {
|
if (uiState.message != null) {
|
||||||
LaunchedEffect(uiState.message) {
|
LaunchedEffect(uiState.message) {
|
||||||
@@ -85,7 +83,7 @@ private fun MangaListView(
|
|||||||
.only(WindowInsetsSides.Horizontal)
|
.only(WindowInsetsSides.Horizontal)
|
||||||
) { padding ->
|
) { padding ->
|
||||||
val listState = rememberLazyGridState()
|
val listState = rememberLazyGridState()
|
||||||
listState.onBottomReached(buffer = 3) {
|
listState.onBottomReached(buffer = 5) {
|
||||||
event?.loadMore()
|
event?.loadMore()
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -22,6 +26,7 @@ import androidx.compose.material.icons.outlined.Info
|
|||||||
import androidx.compose.material.icons.outlined.Palette
|
import androidx.compose.material.icons.outlined.Palette
|
||||||
import androidx.compose.material.icons.outlined.Storage
|
import androidx.compose.material.icons.outlined.Storage
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -32,16 +37,17 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.PreferenceSubtitle
|
|
||||||
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
import org.xtimms.tokusho.core.components.SettingItem
|
import org.xtimms.tokusho.core.components.SettingItem
|
||||||
|
import org.xtimms.tokusho.sections.settings.storage.StorageEvent
|
||||||
|
import org.xtimms.tokusho.sections.settings.storage.StorageUiState
|
||||||
import org.xtimms.tokusho.sections.settings.storage.StorageViewModel
|
import org.xtimms.tokusho.sections.settings.storage.StorageViewModel
|
||||||
import org.xtimms.tokusho.utils.FileSize
|
import org.xtimms.tokusho.utils.FileSize
|
||||||
|
import org.xtimms.tokusho.utils.system.toast
|
||||||
|
|
||||||
const val SETTINGS_DESTINATION = "settings"
|
const val SETTINGS_DESTINATION = "settings"
|
||||||
|
|
||||||
@SuppressLint("BatteryLife")
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsView(
|
fun SettingsView(
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
@@ -51,10 +57,41 @@ fun SettingsView(
|
|||||||
navigateToStorage: () -> Unit
|
navigateToStorage: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val viewModel: StorageViewModel = hiltViewModel()
|
val viewModel: StorageViewModel = hiltViewModel()
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
SettingsViewContent(
|
||||||
|
uiState = uiState,
|
||||||
|
event = viewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToAppearance = navigateToAppearance,
|
||||||
|
navigateToAbout = navigateToAbout,
|
||||||
|
navigateToAdvanced = navigateToAdvanced,
|
||||||
|
navigateToStorage = navigateToStorage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
|
@Composable
|
||||||
|
private fun SettingsViewContent(
|
||||||
|
uiState: StorageUiState,
|
||||||
|
event: StorageEvent?,
|
||||||
|
navigateBack: () -> Unit,
|
||||||
|
navigateToAppearance: () -> Unit,
|
||||||
|
navigateToAbout: () -> Unit,
|
||||||
|
navigateToAdvanced: () -> Unit,
|
||||||
|
navigateToStorage: () -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
if (uiState.message != null) {
|
||||||
|
LaunchedEffect(uiState.message) {
|
||||||
|
context.toast(uiState.message)
|
||||||
|
event?.onMessageDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
var showBatteryHint by remember {
|
var showBatteryHint by remember {
|
||||||
mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
|
mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
|
||||||
@@ -85,8 +122,10 @@ fun SettingsView(
|
|||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(padding),
|
||||||
.padding(padding)
|
contentPadding = PaddingValues(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -123,16 +162,16 @@ fun SettingsView(
|
|||||||
append(
|
append(
|
||||||
FileSize.BYTES.freeFormat(
|
FileSize.BYTES.freeFormat(
|
||||||
context,
|
context,
|
||||||
uiState.availableSpace -
|
(uiState.availableSpace -
|
||||||
uiState.httpCacheSize -
|
uiState.httpCacheSize -
|
||||||
uiState.pagesCache -
|
uiState.pagesCache -
|
||||||
uiState.thumbnailsCache
|
uiState.thumbnailsCache).toFloat()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SettingItem(
|
SettingItem(
|
||||||
title = stringResource(id = R.string.storage),
|
title = stringResource(id = R.string.storage),
|
||||||
description = desc,
|
description = if (uiState.isLoading) context.getString(R.string.calculating_) else desc,
|
||||||
icon = Icons.Outlined.Storage,
|
icon = Icons.Outlined.Storage,
|
||||||
onClick = navigateToStorage
|
onClick = navigateToStorage
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package org.xtimms.tokusho.sections.settings.about
|
package org.xtimms.tokusho.sections.settings.about
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -56,8 +60,10 @@ fun AboutView(
|
|||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(padding),
|
||||||
.padding(padding)
|
contentPadding = PaddingValues(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
PreferenceItem(
|
PreferenceItem(
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -70,8 +73,10 @@ fun UpdateView(
|
|||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(padding),
|
||||||
.padding(padding)
|
contentPadding = PaddingValues(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
PreferenceSwitchWithContainer(
|
PreferenceSwitchWithContainer(
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
package org.xtimms.tokusho.sections.settings.advanced
|
package org.xtimms.tokusho.sections.settings.advanced
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Print
|
||||||
|
import androidx.compose.material.icons.outlined.PrintDisabled
|
||||||
|
import androidx.compose.material.icons.outlined.Report
|
||||||
|
import androidx.compose.material.icons.outlined.ReportOff
|
||||||
|
import androidx.compose.material.icons.outlined.Share
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -16,10 +31,17 @@ import org.xtimms.tokusho.BuildConfig
|
|||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.PreferenceItem
|
import org.xtimms.tokusho.core.components.PreferenceItem
|
||||||
import org.xtimms.tokusho.core.components.PreferenceSubtitle
|
import org.xtimms.tokusho.core.components.PreferenceSubtitle
|
||||||
|
import org.xtimms.tokusho.core.components.PreferenceSwitch
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
|
import org.xtimms.tokusho.core.logs.FileLogger
|
||||||
|
import org.xtimms.tokusho.core.prefs.ACRA
|
||||||
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
|
import org.xtimms.tokusho.core.prefs.LOGGING
|
||||||
import org.xtimms.tokusho.utils.DeviceUtil
|
import org.xtimms.tokusho.utils.DeviceUtil
|
||||||
|
import org.xtimms.tokusho.utils.ShareHelper
|
||||||
import org.xtimms.tokusho.utils.WebViewUtil
|
import org.xtimms.tokusho.utils.WebViewUtil
|
||||||
import org.xtimms.tokusho.utils.lang.toDateTimestampString
|
import org.xtimms.tokusho.utils.lang.toDateTimestampString
|
||||||
|
import org.xtimms.tokusho.utils.system.toast
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -29,17 +51,65 @@ const val ADVANCED_DESTINATION = "advanced"
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AdvancedView(
|
fun AdvancedView(
|
||||||
|
loggers: Set<FileLogger>,
|
||||||
navigateBack: () -> Unit,
|
navigateBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var isAcraEnabled by remember {
|
||||||
|
mutableStateOf(AppSettings.isACRAEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLoggingEnabled by remember {
|
||||||
|
mutableStateOf(AppSettings.isLoggingEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
ScaffoldWithTopAppBar(
|
ScaffoldWithTopAppBar(
|
||||||
title = stringResource(R.string.advanced),
|
title = stringResource(R.string.advanced),
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(padding),
|
||||||
.padding(padding)
|
contentPadding = PaddingValues(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
PreferenceSwitch(
|
||||||
|
title = stringResource(id = R.string.send_crash_reports),
|
||||||
|
description = stringResource(id = R.string.send_crash_reports_desc),
|
||||||
|
icon = if (isAcraEnabled) Icons.Outlined.Report else Icons.Outlined.ReportOff,
|
||||||
|
isChecked = isAcraEnabled,
|
||||||
|
onClick = {
|
||||||
|
isAcraEnabled = !isAcraEnabled
|
||||||
|
AppSettings.updateValue(ACRA, isAcraEnabled)
|
||||||
|
context.toast(R.string.restart_required)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
PreferenceSwitch(
|
||||||
|
title = stringResource(id = R.string.enable_logging),
|
||||||
|
description = stringResource(id = R.string.enable_logging_desc),
|
||||||
|
icon = if (isLoggingEnabled) Icons.Outlined.Print else Icons.Outlined.PrintDisabled,
|
||||||
|
isChecked = isLoggingEnabled,
|
||||||
|
onClick = {
|
||||||
|
isLoggingEnabled = !isLoggingEnabled
|
||||||
|
AppSettings.updateValue(LOGGING, isLoggingEnabled)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
PreferenceItem(
|
||||||
|
title = stringResource(id = R.string.share_logs),
|
||||||
|
icon = Icons.Outlined.Share,
|
||||||
|
enabled = isLoggingEnabled,
|
||||||
|
onClick = {
|
||||||
|
ShareHelper(context).shareLogs(loggers)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceSubtitle(text = stringResource(id = R.string.app_info))
|
PreferenceSubtitle(text = stringResource(id = R.string.app_info))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.outlined.ColorLens
|
|||||||
import androidx.compose.material.icons.outlined.DarkMode
|
import androidx.compose.material.icons.outlined.DarkMode
|
||||||
import androidx.compose.material.icons.outlined.Language
|
import androidx.compose.material.icons.outlined.Language
|
||||||
import androidx.compose.material.icons.outlined.LightMode
|
import androidx.compose.material.icons.outlined.LightMode
|
||||||
|
import androidx.compose.material.icons.outlined.Timelapse
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@@ -35,6 +36,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -53,12 +55,14 @@ import org.xtimms.tokusho.LocalPaletteStyleIndex
|
|||||||
import org.xtimms.tokusho.LocalSeedColor
|
import org.xtimms.tokusho.LocalSeedColor
|
||||||
import org.xtimms.tokusho.R
|
import org.xtimms.tokusho.R
|
||||||
import org.xtimms.tokusho.core.components.PreferenceItem
|
import org.xtimms.tokusho.core.components.PreferenceItem
|
||||||
|
import org.xtimms.tokusho.core.components.PreferenceSubtitle
|
||||||
import org.xtimms.tokusho.core.components.PreferenceSwitch
|
import org.xtimms.tokusho.core.components.PreferenceSwitch
|
||||||
import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider
|
import org.xtimms.tokusho.core.components.PreferenceSwitchWithDivider
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||||
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
|
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
|
||||||
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
|
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.ON
|
||||||
|
import org.xtimms.tokusho.core.prefs.READING_TIME
|
||||||
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
|
import org.xtimms.tokusho.core.prefs.STYLE_MONOCHROME
|
||||||
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
|
import org.xtimms.tokusho.core.prefs.STYLE_TONAL_SPOT
|
||||||
import org.xtimms.tokusho.core.prefs.paletteStyles
|
import org.xtimms.tokusho.core.prefs.paletteStyles
|
||||||
@@ -92,6 +96,10 @@ fun AppearanceView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isReadingTimeEstimationEnabled by remember {
|
||||||
|
mutableStateOf(AppSettings.isReadingTimeEstimationEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
ScaffoldWithTopAppBar(
|
ScaffoldWithTopAppBar(
|
||||||
title = stringResource(R.string.appearance),
|
title = stringResource(R.string.appearance),
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
@@ -99,7 +107,7 @@ fun AppearanceView(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
MangaCard(
|
MangaCard(
|
||||||
modifier = Modifier.padding(18.dp),
|
modifier = Modifier.padding(18.dp),
|
||||||
@@ -180,6 +188,16 @@ fun AppearanceView(
|
|||||||
icon = Icons.Outlined.Language,
|
icon = Icons.Outlined.Language,
|
||||||
description = getLanguageDesc(),
|
description = getLanguageDesc(),
|
||||||
onClick = { navigateToLanguages() })
|
onClick = { navigateToLanguages() })
|
||||||
|
PreferenceSubtitle(text = stringResource(id = R.string.details))
|
||||||
|
PreferenceSwitch(
|
||||||
|
title = stringResource(id = R.string.show_estimated_read_time),
|
||||||
|
description = stringResource(id = R.string.show_estimated_read_time_desc),
|
||||||
|
icon = Icons.Outlined.Timelapse,
|
||||||
|
isChecked = isReadingTimeEstimationEnabled,
|
||||||
|
onClick = {
|
||||||
|
isReadingTimeEstimationEnabled = !isReadingTimeEstimationEnabled
|
||||||
|
AppSettings.updateValue(READING_TIME, isReadingTimeEstimationEnabled)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package org.xtimms.tokusho.sections.settings.appearance
|
package org.xtimms.tokusho.sections.settings.appearance
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -34,7 +38,11 @@ fun DarkThemeView(
|
|||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.padding(padding)) {
|
modifier = Modifier.padding(padding),
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
|
) {
|
||||||
if (Build.VERSION.SDK_INT >= 29)
|
if (Build.VERSION.SDK_INT >= 29)
|
||||||
item {
|
item {
|
||||||
PreferenceSingleChoiceItem(
|
PreferenceSingleChoiceItem(
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -108,8 +111,10 @@ private fun LanguageViewImpl(
|
|||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(padding),
|
||||||
.padding(padding)
|
contentPadding = PaddingValues(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
PreferencesHintCard(
|
PreferencesHintCard(
|
||||||
|
|||||||
@@ -96,7 +96,12 @@ fun CleanDialog(
|
|||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
val summary = StringBuilder().run {
|
val summary = StringBuilder().run {
|
||||||
append(FileSize.BYTES.format(LocalContext.current, uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize))
|
append(
|
||||||
|
FileSize.BYTES.format(
|
||||||
|
LocalContext.current,
|
||||||
|
(uiState.pagesCache + uiState.thumbnailsCache + uiState.httpCacheSize).toFloat()
|
||||||
|
)
|
||||||
|
)
|
||||||
append("")
|
append("")
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -104,7 +109,6 @@ fun CleanDialog(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
// style = MaterialTheme.typography.labelMedium,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.xtimms.tokusho.sections.settings.storage
|
||||||
|
|
||||||
|
import org.xtimms.tokusho.core.base.event.UiEvent
|
||||||
|
|
||||||
|
interface StorageEvent : UiEvent
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.xtimms.tokusho.sections.settings.storage
|
package org.xtimms.tokusho.sections.settings.storage
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -10,13 +12,11 @@ import androidx.compose.material.icons.outlined.CleaningServices
|
|||||||
import androidx.compose.material.icons.outlined.Image
|
import androidx.compose.material.icons.outlined.Image
|
||||||
import androidx.compose.material.icons.outlined.NetworkWifi
|
import androidx.compose.material.icons.outlined.NetworkWifi
|
||||||
import androidx.compose.material.icons.outlined.SdStorage
|
import androidx.compose.material.icons.outlined.SdStorage
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -27,6 +27,7 @@ import org.xtimms.tokusho.core.components.PreferenceStorageHeader
|
|||||||
import org.xtimms.tokusho.core.components.PreferenceStorageItem
|
import org.xtimms.tokusho.core.components.PreferenceStorageItem
|
||||||
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
import org.xtimms.tokusho.core.components.PreferencesHintCard
|
||||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||||
|
import org.xtimms.tokusho.data.CACHE_SIZE_MAX
|
||||||
|
|
||||||
const val STORAGE_DESTINATION = "storage"
|
const val STORAGE_DESTINATION = "storage"
|
||||||
|
|
||||||
@@ -44,14 +45,16 @@ fun StorageView(
|
|||||||
title = stringResource(R.string.storage),
|
title = stringResource(R.string.storage),
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (!uiState.isLoading) LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(padding),
|
||||||
.padding(padding)
|
contentPadding = PaddingValues(
|
||||||
|
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
PreferenceStorageHeader(
|
PreferenceStorageHeader(
|
||||||
used = uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache,
|
used = (uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache).toFloat(),
|
||||||
total = uiState.availableSpace
|
total = uiState.availableSpace.toFloat()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -65,37 +68,35 @@ fun StorageView(
|
|||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceStorageItem(
|
PreferenceStorageItem(
|
||||||
total = uiState.availableSpace,
|
total = uiState.availableSpace.toFloat(),
|
||||||
title = stringResource(id = R.string.saved_manga),
|
title = stringResource(id = R.string.saved_manga),
|
||||||
icon = Icons.Outlined.SdStorage
|
icon = Icons.Outlined.SdStorage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceStorageItem(
|
PreferenceStorageItem(
|
||||||
total = uiState.availableSpace,
|
total = uiState.availableSpace.toFloat(),
|
||||||
title = stringResource(id = R.string.pages_cache),
|
title = stringResource(id = R.string.pages_cache),
|
||||||
icon = Icons.Outlined.AutoStories,
|
icon = Icons.Outlined.AutoStories,
|
||||||
used = uiState.pagesCache
|
used = uiState.pagesCache.toFloat()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceStorageItem(
|
PreferenceStorageItem(
|
||||||
total = uiState.availableSpace,
|
total = uiState.availableSpace.toFloat(),
|
||||||
title = stringResource(id = R.string.thumbnails_cache),
|
title = stringResource(id = R.string.thumbnails_cache),
|
||||||
icon = Icons.Outlined.Image,
|
icon = Icons.Outlined.Image,
|
||||||
used = uiState.thumbnailsCache
|
used = uiState.thumbnailsCache.toFloat()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
PreferenceStorageItem(
|
PreferenceStorageItem(
|
||||||
total = uiState.availableSpace,
|
total = CACHE_SIZE_MAX.toFloat(),
|
||||||
title = stringResource(id = R.string.network_cache),
|
title = stringResource(id = R.string.network_cache),
|
||||||
icon = Icons.Outlined.NetworkWifi,
|
icon = Icons.Outlined.NetworkWifi,
|
||||||
used = uiState.httpCacheSize
|
used = uiState.httpCacheSize.toFloat()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (showCleanDialog) {
|
if (showCleanDialog) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.xtimms.tokusho.sections.settings.storage
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
@@ -11,32 +10,29 @@ import okhttp3.Cache
|
|||||||
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
||||||
import org.xtimms.tokusho.core.cache.CacheDir
|
import org.xtimms.tokusho.core.cache.CacheDir
|
||||||
import org.xtimms.tokusho.data.LocalStorageManager
|
import org.xtimms.tokusho.data.LocalStorageManager
|
||||||
import java.util.EnumMap
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class StorageViewModel @Inject constructor(
|
class StorageViewModel @Inject constructor(
|
||||||
private val storageManager: LocalStorageManager,
|
private val storageManager: LocalStorageManager,
|
||||||
private val httpCache: Cache,
|
private val httpCache: Cache,
|
||||||
) : BaseViewModel<StorageUiState>() {
|
) : BaseViewModel<StorageUiState>(), StorageEvent {
|
||||||
|
|
||||||
val httpCacheSize = MutableStateFlow(-1L)
|
|
||||||
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
|
|
||||||
|
|
||||||
private var storageUsageJob: Job? = null
|
private var storageUsageJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val prevJob = storageUsageJob
|
|
||||||
storageUsageJob = launchJob(Dispatchers.Default) {
|
storageUsageJob = launchJob(Dispatchers.Default) {
|
||||||
prevJob?.cancelAndJoin()
|
setLoading(true)
|
||||||
mutableUiState.update {
|
mutableUiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
availableSpace = storageManager.computeAvailableSize(),
|
availableSpace = storageManager.computeAvailableSize(),
|
||||||
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
|
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
|
||||||
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
|
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
|
||||||
httpCacheSize = runInterruptible { httpCache.size() }
|
httpCacheSize = runInterruptible { httpCache.size() },
|
||||||
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +47,7 @@ class StorageViewModel @Inject constructor(
|
|||||||
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
|
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
|
||||||
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
|
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
|
||||||
httpCacheSize = runInterruptible { httpCache.size() },
|
httpCacheSize = runInterruptible { httpCache.size() },
|
||||||
isLoading = false
|
isLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum class FileSize(private val multiplier: Int) {
|
|||||||
|
|
||||||
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
|
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
|
||||||
|
|
||||||
fun freeFormat(context: Context, amount: Long): String {
|
fun freeFormat(context: Context, amount: Float): String {
|
||||||
val bytes = amount * multiplier
|
val bytes = amount * multiplier
|
||||||
val units = context.getString(R.string.text_file_sizes_free).split('|')
|
val units = context.getString(R.string.text_file_sizes_free).split('|')
|
||||||
if (bytes <= 0) {
|
if (bytes <= 0) {
|
||||||
@@ -33,7 +33,7 @@ enum class FileSize(private val multiplier: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun totalFormat(context: Context, amount: Long): String {
|
fun totalFormat(context: Context, amount: Float): String {
|
||||||
val bytes = amount * multiplier
|
val bytes = amount * multiplier
|
||||||
val units = context.getString(R.string.text_file_sizes_total).split('|')
|
val units = context.getString(R.string.text_file_sizes_total).split('|')
|
||||||
if (bytes <= 0) {
|
if (bytes <= 0) {
|
||||||
@@ -54,7 +54,7 @@ enum class FileSize(private val multiplier: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showUnit(context: Context, amount: Long): String {
|
fun showUnit(context: Context, amount: Float): String {
|
||||||
val bytes = amount * multiplier
|
val bytes = amount * multiplier
|
||||||
val units = context.getString(R.string.text_file_sizes_used).split('|')
|
val units = context.getString(R.string.text_file_sizes_used).split('|')
|
||||||
if (bytes <= 0) {
|
if (bytes <= 0) {
|
||||||
@@ -70,7 +70,7 @@ enum class FileSize(private val multiplier: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun format(context: Context, amount: Long): String {
|
fun format(context: Context, amount: Float): String {
|
||||||
val bytes = amount * multiplier
|
val bytes = amount * multiplier
|
||||||
val units = context.getString(R.string.text_file_sizes).split('|')
|
val units = context.getString(R.string.text_file_sizes).split('|')
|
||||||
if (bytes <= 0) {
|
if (bytes <= 0) {
|
||||||
@@ -91,7 +91,7 @@ enum class FileSize(private val multiplier: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatWithoutUnits(amount: Long): String {
|
fun formatWithoutUnits(amount: Float): String {
|
||||||
val bytes = amount * multiplier
|
val bytes = amount * multiplier
|
||||||
if (bytes <= 0) {
|
if (bytes <= 0) {
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
36
app/src/main/java/org/xtimms/tokusho/utils/ShareHelper.kt
Normal file
36
app/src/main/java/org/xtimms/tokusho/utils/ShareHelper.kt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package org.xtimms.tokusho.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import org.xtimms.tokusho.BuildConfig
|
||||||
|
import org.xtimms.tokusho.R
|
||||||
|
import org.xtimms.tokusho.core.logs.FileLogger
|
||||||
|
|
||||||
|
private const val TYPE_TEXT = "text/plain"
|
||||||
|
|
||||||
|
class ShareHelper(private val context: Context) {
|
||||||
|
|
||||||
|
fun shareLogs(loggers: Collection<FileLogger>) {
|
||||||
|
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||||
|
.setType(TYPE_TEXT)
|
||||||
|
var hasLogs = false
|
||||||
|
for (logger in loggers) {
|
||||||
|
val logFile = logger.file
|
||||||
|
if (!logFile.exists()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
|
||||||
|
intentBuilder.addStream(uri)
|
||||||
|
hasLogs = true
|
||||||
|
}
|
||||||
|
if (hasLogs) {
|
||||||
|
intentBuilder.setChooserTitle(R.string.share_logs)
|
||||||
|
intentBuilder.startChooser()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import java.io.File
|
|||||||
import kotlin.io.path.ExperimentalPathApi
|
import kotlin.io.path.ExperimentalPathApi
|
||||||
import kotlin.io.path.walk
|
import kotlin.io.path.walk
|
||||||
|
|
||||||
|
fun File.subdir(name: String) = File(this, name).also {
|
||||||
|
if (!it.exists()) it.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
fun File.getUriCompat(context: Context): Uri {
|
fun File.getUriCompat(context: Context): Uri {
|
||||||
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
|
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/src/main/res/values/plurals.xml
Normal file
11
app/src/main/res/values/plurals.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<plurals name="hours">
|
||||||
|
<item quantity="one">%1$d hour</item>
|
||||||
|
<item quantity="other">%1$d hours</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="minutes">
|
||||||
|
<item quantity="one">%1$d minute</item>
|
||||||
|
<item quantity="other">%1$d minutes</item>
|
||||||
|
</plurals>
|
||||||
|
</resources>
|
||||||
@@ -99,4 +99,17 @@
|
|||||||
<string name="disable_battery_optimization_summary">Helps with background updates checks</string>
|
<string name="disable_battery_optimization_summary">Helps with background updates checks</string>
|
||||||
<string name="recommended_action">Recommended action</string>
|
<string name="recommended_action">Recommended action</string>
|
||||||
<string name="space_used">% used</string>
|
<string name="space_used">% used</string>
|
||||||
|
<string name="calculating_">Calculating...</string>
|
||||||
|
<string name="send_crash_reports">Send crash reports</string>
|
||||||
|
<string name="send_crash_reports_desc">Helps fix any bugs. No sensitive data will be sent</string>
|
||||||
|
<string name="restart_required">To apply the setting, you need to restart the application</string>
|
||||||
|
<string name="enable_logging">Enable logging</string>
|
||||||
|
<string name="enable_logging_desc">Record some actions for debug purposes. Don\'t turn it on if you\'re not sure what you\'re doing</string>
|
||||||
|
<string name="share_logs">Share logs</string>
|
||||||
|
<string name="remaining_time_pattern">%1$s %2$s</string>
|
||||||
|
<string name="details">Details</string>
|
||||||
|
<string name="show_estimated_read_time">Show estimated reading time</string>
|
||||||
|
<string name="show_estimated_read_time_desc">The time estimation value may be inaccurate</string>
|
||||||
|
<string name="approximate_remaining_time">Approximate remaining time</string>
|
||||||
|
<string name="approximate_reading_time">Approximate reading time</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in New Issue
Block a user