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.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -13,6 +14,9 @@ plugins {
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
val acraAuthLogin: String = gradleLocalProperties(rootDir).getProperty("authLogin") ?: "\"acra_login\""
|
||||
val acraAuthPassword: String = gradleLocalProperties(rootDir).getProperty("authPassword") ?: "\"acra_password\""
|
||||
|
||||
android {
|
||||
namespace = "org.xtimms.tokusho"
|
||||
compileSdk = 34
|
||||
@@ -28,6 +32,10 @@ android {
|
||||
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
|
||||
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"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -43,6 +51,9 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
@@ -93,6 +104,7 @@ dependencies {
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
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.accompanist:accompanist-flowlayout: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.ACCESS_NETWORK_STATE" />
|
||||
<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>
|
||||
<intent>
|
||||
|
||||
@@ -11,11 +11,15 @@ import com.tencent.mmkv.MMKV
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.xtimms.tokusho.core.database.MangaDatabase
|
||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||
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 javax.inject.Inject
|
||||
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?) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import coil.ImageLoader
|
||||
@@ -48,6 +49,7 @@ import kotlinx.coroutines.launch
|
||||
import org.xtimms.tokusho.core.Navigation
|
||||
import org.xtimms.tokusho.core.components.BottomNavBar
|
||||
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.utils.lang.processLifecycleScope
|
||||
import javax.inject.Inject
|
||||
@@ -59,9 +61,18 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (!isTaskRoot) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
setContent {
|
||||
val navController = rememberNavController()
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
@@ -74,6 +85,7 @@ class MainActivity : ComponentActivity() {
|
||||
) {
|
||||
MainView(
|
||||
coil = coil,
|
||||
loggers = loggers,
|
||||
isCompactScreen = isCompactScreen,
|
||||
navController = navController
|
||||
)
|
||||
@@ -112,6 +124,7 @@ class MainActivity : ComponentActivity() {
|
||||
@Composable
|
||||
fun MainView(
|
||||
coil: ImageLoader,
|
||||
loggers: Set<FileLogger>,
|
||||
isCompactScreen: Boolean,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
@@ -161,6 +174,7 @@ fun MainView(
|
||||
) {
|
||||
Navigation(
|
||||
coil = coil,
|
||||
loggers = loggers,
|
||||
navController = navController,
|
||||
isCompactScreen = false,
|
||||
modifier = Modifier,
|
||||
@@ -180,6 +194,7 @@ fun MainView(
|
||||
}
|
||||
Navigation(
|
||||
coil = coil,
|
||||
loggers = loggers,
|
||||
navController = navController,
|
||||
isCompactScreen = true,
|
||||
modifier = Modifier.padding(
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import coil.ImageLoader
|
||||
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.motion.materialSharedAxisXIn
|
||||
import org.xtimms.tokusho.core.motion.materialSharedAxisXOut
|
||||
@@ -62,6 +63,7 @@ fun PathInterpolator.toEasing(): Easing {
|
||||
@Composable
|
||||
fun Navigation(
|
||||
coil: ImageLoader,
|
||||
loggers: Set<FileLogger>,
|
||||
navController: NavHostController,
|
||||
isCompactScreen: Boolean,
|
||||
modifier: Modifier,
|
||||
@@ -180,6 +182,7 @@ fun Navigation(
|
||||
|
||||
composable(ADVANCED_DESTINATION) {
|
||||
AdvancedView(
|
||||
loggers = loggers,
|
||||
navigateBack = navigateBack,
|
||||
)
|
||||
}
|
||||
@@ -220,13 +223,16 @@ fun Navigation(
|
||||
)
|
||||
}
|
||||
|
||||
// TODO
|
||||
composable(
|
||||
route = DETAILS_DESTINATION
|
||||
route = DETAILS_DESTINATION,
|
||||
arguments = listOf(
|
||||
navArgument(MANGA_ID_ARGUMENT.removeFirstAndLast()) {
|
||||
type = NavType.LongType
|
||||
}
|
||||
)
|
||||
) { navEntry ->
|
||||
DetailsView(
|
||||
coil = coil,
|
||||
mangaId = 0L,
|
||||
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
|
||||
|
||||
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.background
|
||||
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.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -186,14 +195,28 @@ internal fun PreferenceItemDescription(
|
||||
color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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),
|
||||
text = text,
|
||||
maxLines = maxLines,
|
||||
style = style,
|
||||
color = color.applyOpacity(enabled),
|
||||
overflow = overflow
|
||||
)
|
||||
label = "Preference desc"
|
||||
) { targetText ->
|
||||
Text(
|
||||
text = targetText,
|
||||
maxLines = maxLines,
|
||||
style = style,
|
||||
color = color.applyOpacity(enabled),
|
||||
overflow = overflow
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -552,9 +575,16 @@ fun PreferencesHintCard(
|
||||
|
||||
@Composable
|
||||
fun PreferenceStorageHeader(
|
||||
used: Long = 4L,
|
||||
total: Long = 128L
|
||||
used: Float = 40F,
|
||||
total: Float = 128F
|
||||
) {
|
||||
|
||||
val animatedProgress = animateFloatAsState(
|
||||
targetValue = 1 - ((total - used) / total),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
label = "Progress"
|
||||
).value
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -562,27 +592,47 @@ fun PreferenceStorageHeader(
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = FileSize.BYTES.formatWithoutUnits(used),
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
style = MaterialTheme.typography.displayLarge
|
||||
AnimatedNumber(
|
||||
value = FileSize.BYTES.formatWithoutUnits(used)
|
||||
)
|
||||
Text(
|
||||
text = FileSize.BYTES.showUnit(LocalContext.current, used),
|
||||
AnimatedContent(
|
||||
targetState = used,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
} else {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.Bottom)
|
||||
.padding(PaddingValues(bottom = 8.dp))
|
||||
)
|
||||
Text(
|
||||
text = FileSize.BYTES.totalFormat(LocalContext.current, total),
|
||||
.padding(PaddingValues(start = 4.dp, bottom = 8.dp)),
|
||||
label = "Unit"
|
||||
) { targetUsed ->
|
||||
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
|
||||
.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(
|
||||
progress = { (1 - ((total - used) / total.toFloat())) },
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier
|
||||
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
|
||||
.height(16.dp)
|
||||
@@ -598,13 +648,20 @@ fun PreferenceStorageHeader(
|
||||
@Composable
|
||||
fun PreferenceStorageItem(
|
||||
title: String,
|
||||
used: Long? = 0L,
|
||||
total: Long?,
|
||||
used: Float = 0F,
|
||||
total: Float = 0F,
|
||||
icon: Any? = null,
|
||||
leadingIcon: (@Composable () -> Unit)? = null,
|
||||
trailingIcon: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
|
||||
val animatedProgress = animateFloatAsState(
|
||||
targetValue = 1 - ((total - used) / total),
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
label = "Progress"
|
||||
).value
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
@@ -658,20 +715,30 @@ fun PreferenceStorageItem(
|
||||
text = title,
|
||||
enabled = true
|
||||
)
|
||||
Text(text = FileSize.BYTES.format(LocalContext.current, used ?: 0L))
|
||||
}
|
||||
if (total != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { (1 - ((total - used!!) / total.toFloat())) },
|
||||
modifier = Modifier
|
||||
.padding(PaddingValues(top = 12.dp))
|
||||
.height(5.dp)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = used,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
} else {
|
||||
(fadeIn()).togetherWith(fadeOut())
|
||||
}.using(SizeTransform(clip = false))
|
||||
},
|
||||
label = "Total used"
|
||||
) { targetTotal ->
|
||||
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 {
|
||||
VerticalDivider(
|
||||
@@ -700,7 +767,7 @@ fun PreferenceStorageHeaderPreview() {
|
||||
@Preview(showBackground = true)
|
||||
fun PreferenceStorageItemPreview() {
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -29,7 +31,8 @@ fun ScaffoldWithTopAppBar(
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection),
|
||||
.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection)
|
||||
.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
|
||||
topBar = {
|
||||
DefaultTopAppBar(
|
||||
title = title,
|
||||
@@ -38,8 +41,7 @@ fun ScaffoldWithTopAppBar(
|
||||
)
|
||||
},
|
||||
floatingActionButton = floatingActionButton,
|
||||
contentWindowInsets = WindowInsets.systemBars
|
||||
.only(WindowInsetsSides.Horizontal),
|
||||
contentWindowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -60,12 +65,24 @@ fun SettingItem(title: String, description: String, icon: ImageVector?, onClick:
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = description,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
(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_TAGS = "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 androidx.room.Database
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.room.Room
|
||||
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.MangaSourcesDao
|
||||
import org.xtimms.tokusho.core.database.entity.HistoryEntity
|
||||
import org.xtimms.tokusho.core.database.entity.MangaEntity
|
||||
import org.xtimms.tokusho.core.database.entity.MangaSourceEntity
|
||||
import org.xtimms.tokusho.core.database.entity.MangaTagsEntity
|
||||
import org.xtimms.tokusho.core.database.entity.TagEntity
|
||||
import org.xtimms.tokusho.utils.lang.processLifecycleScope
|
||||
|
||||
const val DATABASE_VERSION = 1
|
||||
|
||||
@@ -18,12 +27,15 @@ const val DATABASE_VERSION = 1
|
||||
MangaEntity::class,
|
||||
TagEntity::class,
|
||||
MangaTagsEntity::class,
|
||||
MangaSourceEntity::class
|
||||
MangaSourceEntity::class,
|
||||
HistoryEntity::class
|
||||
],
|
||||
version = DATABASE_VERSION
|
||||
)
|
||||
abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun getHistoryDao(): HistoryDao
|
||||
|
||||
abstract fun getMangaDao(): MangaDao
|
||||
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
@@ -32,4 +44,14 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
.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.util.mapToSet
|
||||
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.utils.lang.longHashCode
|
||||
import java.time.Instant
|
||||
|
||||
// Entity to model
|
||||
|
||||
@@ -73,4 +75,13 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
|
||||
fun MangaState(name: String): MangaState? = runCatching {
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
public object MotionConstants {
|
||||
public const val DefaultMotionDuration: Int = 300
|
||||
public const val DefaultFadeInDuration: Int = 150
|
||||
public const val DefaultFadeOutDuration: Int = 75
|
||||
public val DefaultSlideDistance: Dp = 30.dp
|
||||
object MotionConstants {
|
||||
const val DefaultMotionDuration: Int = 300
|
||||
const val DefaultFadeInDuration: Int = 150
|
||||
const val DefaultFadeOutDuration: Int = 75
|
||||
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.MutableCookieJar
|
||||
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 java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
@@ -55,6 +59,8 @@ interface NetworkModule {
|
||||
writeTimeout(20, TimeUnit.SECONDS)
|
||||
cookieJar(cookieJar)
|
||||
cache(cache)
|
||||
addInterceptor(GZipInterceptor())
|
||||
addInterceptor(RateLimitInterceptor())
|
||||
}.build()
|
||||
|
||||
@Provides
|
||||
@@ -62,7 +68,11 @@ interface NetworkModule {
|
||||
@MangaHttpClient
|
||||
fun provideMangaHttpClient(
|
||||
@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.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||
@@ -60,6 +61,9 @@ class RemoteMangaRepository(
|
||||
val domains: Array<out String>
|
||||
get() = parser.configKeyDomain.presetValues
|
||||
|
||||
val headers: Headers
|
||||
get() = parser.headers
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
return if (parser is Interceptor) {
|
||||
parser.intercept(chain)
|
||||
|
||||
@@ -26,12 +26,16 @@ const val UPDATE_CHANNEL = "update_channel"
|
||||
private const val THEME_COLOR = "theme_color"
|
||||
const val PALETTE_STYLE = "palette_style"
|
||||
const val LANGUAGE = "language"
|
||||
const val READING_TIME = "reading_time"
|
||||
|
||||
const val SYSTEM_DEFAULT = 0
|
||||
|
||||
const val STABLE = 0
|
||||
const val PRE_RELEASE = 1
|
||||
|
||||
const val ACRA = "acra"
|
||||
const val LOGGING = "logging"
|
||||
|
||||
val paletteStyles = listOf(
|
||||
PaletteStyle.TonalSpot,
|
||||
PaletteStyle.Spritz,
|
||||
@@ -87,6 +91,12 @@ object AppSettings {
|
||||
|
||||
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)) =
|
||||
languageMap.getOrElse(languageNumber) { "" }
|
||||
|
||||
@@ -112,13 +122,10 @@ object AppSettings {
|
||||
private val mutableAppSettingsStateFlow = MutableStateFlow(
|
||||
Settings(
|
||||
DarkThemePreference(
|
||||
darkThemeValue = kv.decodeInt(
|
||||
DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM
|
||||
), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
|
||||
),
|
||||
isDynamicColorEnabled = kv.decodeBool(
|
||||
DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()
|
||||
darkThemeValue = kv.decodeInt(DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM),
|
||||
isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
|
||||
),
|
||||
isDynamicColorEnabled = kv.decodeBool(DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()),
|
||||
seedColor = kv.decodeInt(THEME_COLOR, SEED),
|
||||
paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ private const val DIR_NAME = "manga"
|
||||
private const val NOMEDIA = ".nomedia"
|
||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||
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
|
||||
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
|
||||
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.base.state.UiState
|
||||
import org.xtimms.tokusho.sections.details.data.MangaDetails
|
||||
|
||||
data class DetailsUiState(
|
||||
val manga: Manga? = null,
|
||||
val details: MangaDetails? = null,
|
||||
override val isLoading: Boolean = false,
|
||||
override val message: String? = null,
|
||||
) : UiState() {
|
||||
|
||||
@@ -7,39 +7,41 @@ import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.ImageLoader
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
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.PreferenceItem
|
||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||
|
||||
const val MANGA_ID_ARGUMENT = "{mangaId}"
|
||||
const val DETAILS_DESTINATION = "details/$MANGA_ID_ARGUMENT"
|
||||
const val DETAILS_DESTINATION = "details/?mangaId=$MANGA_ID_ARGUMENT"
|
||||
|
||||
@Composable
|
||||
fun DetailsView(
|
||||
coil: ImageLoader,
|
||||
mangaId: Long,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
|
||||
val context = LocalContext.current
|
||||
val viewModel: DetailsViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val chapterListState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(mangaId) {
|
||||
viewModel.getDetails(mangaId)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val isFirstItemVisible by remember {
|
||||
@@ -57,7 +59,7 @@ fun DetailsView(
|
||||
label = "Top Bar Background",
|
||||
)
|
||||
DetailsToolbar(
|
||||
title = "Test",
|
||||
title = uiState.details?.toManga()?.title ?: "Unknown",
|
||||
titleAlphaProvider = { animatedTitleAlpha },
|
||||
backgroundAlphaProvider = { animatedBgAlpha },
|
||||
onBackClicked = { navigateBack() }
|
||||
@@ -84,26 +86,41 @@ fun DetailsView(
|
||||
) {
|
||||
DetailsInfoBox(
|
||||
coil = coil,
|
||||
imageUrl = uiState.manga?.largeCoverUrl ?: "",
|
||||
title = uiState.manga?.title ?: "",
|
||||
author = uiState.manga?.author ?: "",
|
||||
imageUrl = uiState.details?.toManga()?.largeCoverUrl ?: "",
|
||||
title = uiState.details?.toManga()?.title ?: "",
|
||||
author = uiState.details?.toManga()?.author ?: "",
|
||||
artist = "",
|
||||
state = uiState.manga?.state ?: MangaState.FINISHED,
|
||||
state = uiState.details?.toManga()?.state ?: MangaState.FINISHED,
|
||||
isTabletUi = false,
|
||||
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(
|
||||
key = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
||||
contentType = DetailsViewItem.DESCRIPTION_WITH_TAG,
|
||||
) {
|
||||
ExpandableMangaDescription(
|
||||
defaultExpandState = true,
|
||||
description = uiState.manga?.description ?: "",
|
||||
tagsProvider = { uiState.manga?.tags?.toList() },
|
||||
onTagSearch = { },
|
||||
onCopyTagToClipboard = { },
|
||||
description = uiState.details?.toManga()?.description ?: "",
|
||||
tagsProvider = { uiState.details?.toManga()?.tags?.toList() },
|
||||
onTagSearch = { },
|
||||
onCopyTagToClipboard = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -14,42 +16,80 @@ import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
||||
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.domain.DetailsLoadUseCase
|
||||
import org.xtimms.tokusho.sections.details.domain.ReadingTimeUseCase
|
||||
import org.xtimms.tokusho.utils.lang.onEachWhile
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DetailsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
private val readingTimeUseCase: ReadingTimeUseCase,
|
||||
) : 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())
|
||||
|
||||
fun getDetails(mangaId: Long) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
detailsLoadUseCase.invoke(intent)
|
||||
.onEachWhile {
|
||||
if (it.allChapters.isEmpty()) {
|
||||
return@onEachWhile false
|
||||
}
|
||||
true
|
||||
}.collect {
|
||||
mutableUiState.update {
|
||||
val manga = details.firstOrNull { it != null } ?: return@collect
|
||||
it.copy(
|
||||
manga = manga.toManga()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
private val mangaId = intent.id
|
||||
|
||||
private var loadingJob: Job
|
||||
|
||||
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, false) })
|
||||
val mangaD = details.map { x -> x?.toManga() }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val history = historyRepository.observeOne(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
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.parseAsHtml
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.recoverNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
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.sections.details.data.MangaDetails
|
||||
import org.xtimms.tokusho.utils.lang.sanitize
|
||||
@@ -29,9 +25,9 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
private val imageGetter: Html.ImageGetter,
|
||||
) {
|
||||
|
||||
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
|
||||
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) {
|
||||
"Cannot resolve intent $intent"
|
||||
operator fun invoke(mangaId: Long): Flow<MangaDetails> = channelFlow {
|
||||
val manga = requireNotNull(mangaDataRepository.findMangaById(mangaId)) {
|
||||
"Cannot resolve id $mangaId"
|
||||
}
|
||||
send(MangaDetails(manga, null, false))
|
||||
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 org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.xtimms.tokusho.R
|
||||
import org.xtimms.tokusho.core.collapsable
|
||||
import org.xtimms.tokusho.core.components.ExploreButton
|
||||
import org.xtimms.tokusho.core.components.SourceItem
|
||||
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.items
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -49,7 +48,7 @@ fun MangaListView(
|
||||
val viewModel: MangaListViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
MangaListView(
|
||||
MangaListViewContent(
|
||||
coil = coil,
|
||||
source = source,
|
||||
uiState = uiState,
|
||||
@@ -60,7 +59,7 @@ fun MangaListView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MangaListView(
|
||||
private fun MangaListViewContent(
|
||||
coil: ImageLoader,
|
||||
source: MangaSource,
|
||||
uiState: MangaListUiState,
|
||||
@@ -69,7 +68,6 @@ private fun MangaListView(
|
||||
navigateToDetails: (Long) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
if (uiState.message != null) {
|
||||
LaunchedEffect(uiState.message) {
|
||||
@@ -85,7 +83,7 @@ private fun MangaListView(
|
||||
.only(WindowInsetsSides.Horizontal)
|
||||
) { padding ->
|
||||
val listState = rememberLazyGridState()
|
||||
listState.onBottomReached(buffer = 3) {
|
||||
listState.onBottomReached(buffer = 5) {
|
||||
event?.loadMore()
|
||||
}
|
||||
Column(
|
||||
|
||||
@@ -13,6 +13,10 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeOut
|
||||
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.lazy.LazyColumn
|
||||
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.Storage
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -32,16 +37,17 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.ScaffoldWithTopAppBar
|
||||
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.utils.FileSize
|
||||
import org.xtimms.tokusho.utils.system.toast
|
||||
|
||||
const val SETTINGS_DESTINATION = "settings"
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@Composable
|
||||
fun SettingsView(
|
||||
navigateBack: () -> Unit,
|
||||
@@ -51,10 +57,41 @@ fun SettingsView(
|
||||
navigateToStorage: () -> Unit
|
||||
) {
|
||||
|
||||
val context = LocalContext.current
|
||||
val viewModel: StorageViewModel = hiltViewModel()
|
||||
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
|
||||
var showBatteryHint by remember {
|
||||
mutableStateOf(!pm.isIgnoringBatteryOptimizations(context.packageName))
|
||||
@@ -85,8 +122,10 @@ fun SettingsView(
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item {
|
||||
AnimatedVisibility(
|
||||
@@ -123,16 +162,16 @@ fun SettingsView(
|
||||
append(
|
||||
FileSize.BYTES.freeFormat(
|
||||
context,
|
||||
uiState.availableSpace -
|
||||
(uiState.availableSpace -
|
||||
uiState.httpCacheSize -
|
||||
uiState.pagesCache -
|
||||
uiState.thumbnailsCache
|
||||
uiState.thumbnailsCache).toFloat()
|
||||
)
|
||||
)
|
||||
}
|
||||
SettingItem(
|
||||
title = stringResource(id = R.string.storage),
|
||||
description = desc,
|
||||
description = if (uiState.isLoading) context.getString(R.string.calculating_) else desc,
|
||||
icon = Icons.Outlined.Storage,
|
||||
onClick = navigateToStorage
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -56,8 +60,10 @@ fun AboutView(
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item {
|
||||
PreferenceItem(
|
||||
|
||||
@@ -4,7 +4,10 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -70,8 +73,10 @@ fun UpdateView(
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item {
|
||||
PreferenceSwitchWithContainer(
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
package org.xtimms.tokusho.sections.settings.advanced
|
||||
|
||||
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.statusBarsPadding
|
||||
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.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -16,10 +31,17 @@ import org.xtimms.tokusho.BuildConfig
|
||||
import org.xtimms.tokusho.R
|
||||
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.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.ShareHelper
|
||||
import org.xtimms.tokusho.utils.WebViewUtil
|
||||
import org.xtimms.tokusho.utils.lang.toDateTimestampString
|
||||
import org.xtimms.tokusho.utils.system.toast
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@@ -29,17 +51,65 @@ const val ADVANCED_DESTINATION = "advanced"
|
||||
|
||||
@Composable
|
||||
fun AdvancedView(
|
||||
loggers: Set<FileLogger>,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
var isAcraEnabled by remember {
|
||||
mutableStateOf(AppSettings.isACRAEnabled())
|
||||
}
|
||||
|
||||
var isLoggingEnabled by remember {
|
||||
mutableStateOf(AppSettings.isLoggingEnabled())
|
||||
}
|
||||
|
||||
ScaffoldWithTopAppBar(
|
||||
title = stringResource(R.string.advanced),
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
modifier = Modifier.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 {
|
||||
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.Language
|
||||
import androidx.compose.material.icons.outlined.LightMode
|
||||
import androidx.compose.material.icons.outlined.Timelapse
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -35,6 +36,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -53,12 +55,14 @@ import org.xtimms.tokusho.LocalPaletteStyleIndex
|
||||
import org.xtimms.tokusho.LocalSeedColor
|
||||
import org.xtimms.tokusho.R
|
||||
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.PreferenceSwitchWithDivider
|
||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||
import org.xtimms.tokusho.core.prefs.AppSettings
|
||||
import org.xtimms.tokusho.core.prefs.DarkThemePreference.Companion.OFF
|
||||
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_TONAL_SPOT
|
||||
import org.xtimms.tokusho.core.prefs.paletteStyles
|
||||
@@ -92,6 +96,10 @@ fun AppearanceView(
|
||||
)
|
||||
}
|
||||
|
||||
var isReadingTimeEstimationEnabled by remember {
|
||||
mutableStateOf(AppSettings.isReadingTimeEstimationEnabled())
|
||||
}
|
||||
|
||||
ScaffoldWithTopAppBar(
|
||||
title = stringResource(R.string.appearance),
|
||||
navigateBack = navigateBack
|
||||
@@ -99,7 +107,7 @@ fun AppearanceView(
|
||||
Column(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
MangaCard(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
@@ -180,6 +188,16 @@ fun AppearanceView(
|
||||
icon = Icons.Outlined.Language,
|
||||
description = getLanguageDesc(),
|
||||
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
|
||||
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -34,7 +38,11 @@ fun DarkThemeView(
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding)) {
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= 29)
|
||||
item {
|
||||
PreferenceSingleChoiceItem(
|
||||
|
||||
@@ -8,7 +8,10 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -108,8 +111,10 @@ private fun LanguageViewImpl(
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item {
|
||||
PreferencesHintCard(
|
||||
|
||||
@@ -96,7 +96,12 @@ fun CleanDialog(
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
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("")
|
||||
}
|
||||
Text(
|
||||
@@ -104,7 +109,6 @@ fun CleanDialog(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.lazy.LazyColumn
|
||||
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.NetworkWifi
|
||||
import androidx.compose.material.icons.outlined.SdStorage
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.PreferencesHintCard
|
||||
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
|
||||
import org.xtimms.tokusho.data.CACHE_SIZE_MAX
|
||||
|
||||
const val STORAGE_DESTINATION = "storage"
|
||||
|
||||
@@ -44,14 +45,16 @@ fun StorageView(
|
||||
title = stringResource(R.string.storage),
|
||||
navigateBack = navigateBack
|
||||
) { padding ->
|
||||
if (!uiState.isLoading) LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item {
|
||||
PreferenceStorageHeader(
|
||||
used = uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache,
|
||||
total = uiState.availableSpace
|
||||
used = (uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache).toFloat(),
|
||||
total = uiState.availableSpace.toFloat()
|
||||
)
|
||||
}
|
||||
item {
|
||||
@@ -65,37 +68,35 @@ fun StorageView(
|
||||
}
|
||||
item {
|
||||
PreferenceStorageItem(
|
||||
total = uiState.availableSpace,
|
||||
total = uiState.availableSpace.toFloat(),
|
||||
title = stringResource(id = R.string.saved_manga),
|
||||
icon = Icons.Outlined.SdStorage
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceStorageItem(
|
||||
total = uiState.availableSpace,
|
||||
total = uiState.availableSpace.toFloat(),
|
||||
title = stringResource(id = R.string.pages_cache),
|
||||
icon = Icons.Outlined.AutoStories,
|
||||
used = uiState.pagesCache
|
||||
used = uiState.pagesCache.toFloat()
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceStorageItem(
|
||||
total = uiState.availableSpace,
|
||||
total = uiState.availableSpace.toFloat(),
|
||||
title = stringResource(id = R.string.thumbnails_cache),
|
||||
icon = Icons.Outlined.Image,
|
||||
used = uiState.thumbnailsCache
|
||||
used = uiState.thumbnailsCache.toFloat()
|
||||
)
|
||||
}
|
||||
item {
|
||||
PreferenceStorageItem(
|
||||
total = uiState.availableSpace,
|
||||
total = CACHE_SIZE_MAX.toFloat(),
|
||||
title = stringResource(id = R.string.network_cache),
|
||||
icon = Icons.Outlined.NetworkWifi,
|
||||
used = uiState.httpCacheSize
|
||||
used = uiState.httpCacheSize.toFloat()
|
||||
)
|
||||
}
|
||||
} else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
if (showCleanDialog) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.xtimms.tokusho.sections.settings.storage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
@@ -11,32 +10,29 @@ import okhttp3.Cache
|
||||
import org.xtimms.tokusho.core.base.viewmodel.BaseViewModel
|
||||
import org.xtimms.tokusho.core.cache.CacheDir
|
||||
import org.xtimms.tokusho.data.LocalStorageManager
|
||||
import java.util.EnumMap
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class StorageViewModel @Inject constructor(
|
||||
private val storageManager: LocalStorageManager,
|
||||
private val httpCache: Cache,
|
||||
) : BaseViewModel<StorageUiState>() {
|
||||
|
||||
val httpCacheSize = MutableStateFlow(-1L)
|
||||
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
|
||||
) : BaseViewModel<StorageUiState>(), StorageEvent {
|
||||
|
||||
private var storageUsageJob: Job? = null
|
||||
|
||||
init {
|
||||
val prevJob = storageUsageJob
|
||||
storageUsageJob = launchJob(Dispatchers.Default) {
|
||||
prevJob?.cancelAndJoin()
|
||||
setLoading(true)
|
||||
mutableUiState.update {
|
||||
it.copy(
|
||||
availableSpace = storageManager.computeAvailableSize(),
|
||||
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
|
||||
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),
|
||||
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
|
||||
httpCacheSize = runInterruptible { httpCache.size() },
|
||||
isLoading = false
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
} 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 freeFormat(context: Context, amount: Long): String {
|
||||
fun freeFormat(context: Context, amount: Float): String {
|
||||
val bytes = amount * multiplier
|
||||
val units = context.getString(R.string.text_file_sizes_free).split('|')
|
||||
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 units = context.getString(R.string.text_file_sizes_total).split('|')
|
||||
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 units = context.getString(R.string.text_file_sizes_used).split('|')
|
||||
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 units = context.getString(R.string.text_file_sizes).split('|')
|
||||
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
|
||||
if (bytes <= 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.walk
|
||||
|
||||
fun File.subdir(name: String) = File(this, name).also {
|
||||
if (!it.exists()) it.mkdirs()
|
||||
}
|
||||
|
||||
fun File.getUriCompat(context: Context): Uri {
|
||||
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="recommended_action">Recommended action</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>
|
||||
Reference in New Issue
Block a user