Storage screen

master
Zakhar Timoshenko 2 years ago
parent 98451e55e2
commit 2a6edad196
Signed by: Xtimms
SSH Key Fingerprint: SHA256:wH6spYepK/A5erBh7ZyAnr1ru9H4eaMVBEuiw6DSpxI

@ -84,8 +84,8 @@ dependencies {
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material:material-icons-extended:1.6.0") implementation("androidx.compose.material:material-icons-extended:1.6.0")
implementation("androidx.compose.material3:material3-android:1.2.0-rc01") implementation("androidx.compose.material3:material3-android:1.2.0")
implementation("androidx.compose.material3:material3-window-size-class:1.2.0-rc01") implementation("androidx.compose.material3:material3-window-size-class:1.2.0")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0") implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
implementation("androidx.navigation:navigation-compose:2.7.6") implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.profileinstaller:profileinstaller:1.3.1") implementation("androidx.profileinstaller:profileinstaller:1.3.1")
@ -94,6 +94,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("androidx.work:work-runtime-ktx:2.9.0")
ksp("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.6.1")
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.11.0")
implementation("com.google.accompanist:accompanist-flowlayout:0.32.0")
implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0") implementation("com.google.accompanist:accompanist-systemuicontroller:0.32.0")
implementation("com.google.accompanist:accompanist-pager:0.32.0") implementation("com.google.accompanist:accompanist-pager:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0") implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")

@ -45,6 +45,8 @@ import org.xtimms.tokusho.sections.settings.appearance.DARK_THEME_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView import org.xtimms.tokusho.sections.settings.appearance.DarkThemeView
import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION import org.xtimms.tokusho.sections.settings.appearance.LANGUAGES_DESTINATION
import org.xtimms.tokusho.sections.settings.appearance.LanguagesView import org.xtimms.tokusho.sections.settings.appearance.LanguagesView
import org.xtimms.tokusho.sections.settings.storage.STORAGE_DESTINATION
import org.xtimms.tokusho.sections.settings.storage.StorageView
import org.xtimms.tokusho.sections.shelf.ShelfMap import org.xtimms.tokusho.sections.shelf.ShelfMap
import org.xtimms.tokusho.sections.shelf.ShelfView import org.xtimms.tokusho.sections.shelf.ShelfView
import org.xtimms.tokusho.utils.lang.removeFirstAndLast import org.xtimms.tokusho.utils.lang.removeFirstAndLast
@ -144,7 +146,8 @@ fun Navigation(
navigateBack = navigateBack, navigateBack = navigateBack,
navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) }, navigateToAppearance = { navController.navigate(APPEARANCE_DESTINATION) },
navigateToAbout = { navController.navigate(ABOUT_DESTINATION) }, navigateToAbout = { navController.navigate(ABOUT_DESTINATION) },
navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) } navigateToAdvanced = { navController.navigate(ADVANCED_DESTINATION) },
navigateToStorage = { navController.navigate(STORAGE_DESTINATION) }
) )
} }
@ -169,6 +172,12 @@ fun Navigation(
) )
} }
composable(STORAGE_DESTINATION) {
StorageView(
navigateBack = navigateBack,
)
}
composable(ADVANCED_DESTINATION) { composable(ADVANCED_DESTINATION) {
AdvancedView( AdvancedView(
navigateBack = navigateBack, navigateBack = navigateBack,

@ -9,8 +9,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.xtimms.tokusho.R
@Composable
fun ConfirmButton(
text: String = stringResource(R.string.confirm),
enabled: Boolean = true,
onClick: () -> Unit
) {
TextButton(onClick = onClick, enabled = enabled) {
Text(text)
}
}
@Composable
fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) {
TextButton(onClick = onClick) {
Text(text)
}
}
@Composable @Composable
fun ActionButton( fun ActionButton(

@ -0,0 +1,44 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.dp
@Composable
fun DialogCheckBoxItem(
modifier: Modifier = Modifier,
text: String,
checked: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = modifier
.fillMaxWidth()
.toggleable(
value = checked,
enabled = true,
onValueChange = { onClick() },
)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
modifier = Modifier.clearAndSetSemantics { },
checked = checked, onCheckedChange = { onClick() },
)
Text(
modifier = Modifier.weight(1f),
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}

@ -0,0 +1,137 @@
package org.xtimms.tokusho.core.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
private val DialogVerticalPadding = PaddingValues(vertical = 24.dp)
private val IconPadding = PaddingValues(bottom = 16.dp)
private val DialogHorizontalPadding = PaddingValues(horizontal = 24.dp)
private val TitlePadding = PaddingValues(bottom = 16.dp)
private val TextPadding = PaddingValues(bottom = 24.dp)
private val ButtonsMainAxisSpacing = Arrangement.spacedBy(8.dp, Alignment.Start)
private val ButtonsCrossAxisSpacing = Arrangement.spacedBy(12.dp, Alignment.Top)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun TokushoDialog(
onDismissRequest: () -> Unit,
confirmButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
dismissButton: @Composable (() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
shape: Shape = AlertDialogDefaults.shape,
containerColor: Color = AlertDialogDefaults.containerColor,
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
titleContentColor: Color = AlertDialogDefaults.titleContentColor,
textContentColor: Color = AlertDialogDefaults.textContentColor,
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
properties: DialogProperties = DialogProperties()
) {
AlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
properties = properties
) {
Surface(
modifier = modifier,
shape = shape,
color = containerColor,
tonalElevation = tonalElevation,
) {
Column(
modifier = Modifier.padding(DialogVerticalPadding)
) {
icon?.let {
CompositionLocalProvider(LocalContentColor provides iconContentColor) {
Box(
Modifier
.padding(IconPadding)
.padding(DialogHorizontalPadding)
.align(Alignment.CenterHorizontally)
) {
icon()
}
}
}
title?.let {
CompositionLocalProvider(LocalContentColor provides titleContentColor) {
val textStyle = MaterialTheme.typography.headlineSmall
ProvideTextStyle(textStyle) {
Box(
// Align the title to the center when an icon is present.
Modifier
.padding(TitlePadding)
.padding(DialogHorizontalPadding)
.align(
if (icon == null) {
Alignment.Start
} else {
Alignment.CenterHorizontally
}
)
) {
title()
}
}
}
}
text?.let {
CompositionLocalProvider(LocalContentColor provides textContentColor) {
val textStyle =
MaterialTheme.typography.bodyMedium
ProvideTextStyle(textStyle) {
Box(
Modifier
.weight(weight = 1f, fill = false)
.padding(TextPadding)
.align(Alignment.Start)
) {
text()
}
}
}
}
Box(
modifier = Modifier
.align(Alignment.End)
.padding(DialogHorizontalPadding)
) {
val textStyle =
MaterialTheme.typography.labelLarge
ProvideTextStyle(value = textStyle) {
FlowRow(
horizontalArrangement = ButtonsMainAxisSpacing,
verticalArrangement = ButtonsCrossAxisSpacing
) {
dismissButton?.invoke()
confirmButton()
}
}
}
}
}
}
}

@ -16,14 +16,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.TipsAndUpdates
import androidx.compose.material.icons.outlined.Call import androidx.compose.material.icons.outlined.Call
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.TipsAndUpdates
import androidx.compose.material.icons.outlined.ToggleOn import androidx.compose.material.icons.outlined.ToggleOn
import androidx.compose.material.icons.outlined.Translate import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -41,8 +43,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@ -57,8 +61,10 @@ import org.xtimms.tokusho.R
import org.xtimms.tokusho.ui.monet.LocalTonalPalettes import org.xtimms.tokusho.ui.monet.LocalTonalPalettes
import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes import org.xtimms.tokusho.ui.monet.TonalPalettes.Companion.toTonalPalettes
import org.xtimms.tokusho.ui.theme.PreviewThemeLight import org.xtimms.tokusho.ui.theme.PreviewThemeLight
import org.xtimms.tokusho.ui.theme.TokushoTheme
import org.xtimms.tokusho.ui.theme.applyOpacity import org.xtimms.tokusho.ui.theme.applyOpacity
import org.xtimms.tokusho.ui.theme.preferenceTitle import org.xtimms.tokusho.ui.theme.preferenceTitle
import org.xtimms.tokusho.utils.FileSize
private const val horizontal = 8 private const val horizontal = 8
private const val vertical = 16 private const val vertical = 16
@ -516,7 +522,7 @@ fun PreferencesHintCard(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(start = 8.dp, end = 16.dp) .padding(start = 8.dp, end = 24.dp)
.size(24.dp), .size(24.dp),
tint = contentColor tint = contentColor
) )
@ -544,6 +550,160 @@ fun PreferencesHintCard(
} }
} }
@Composable
fun PreferenceStorageHeader(
used: Long = 4L,
total: Long = 128L
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.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
)
Text(
text = FileSize.BYTES.showUnit(LocalContext.current, used),
modifier = Modifier
.weight(1f)
.align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp))
)
Text(
text = FileSize.BYTES.totalFormat(LocalContext.current, total),
modifier = Modifier
.align(Alignment.Bottom)
.padding(PaddingValues(bottom = 8.dp))
)
}
LinearProgressIndicator(
progress = { (1 - ((total - used) / total.toFloat())) },
modifier = Modifier
.padding(PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp))
.height(16.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
strokeCap = StrokeCap.Round,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PreferenceStorageItem(
title: String,
used: Long? = 0L,
total: Long?,
icon: Any? = null,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {},
) {
Surface(
modifier = Modifier.combinedClickable(
onClick = onClick,
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal.dp, vertical.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingIcon?.invoke()
when (icon) {
is ImageVector -> {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true),
)
}
is Painter -> {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp, end = 16.dp)
.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.applyOpacity(true),
)
}
}
Column(
modifier = Modifier
.weight(1f)
.then(
if (icon != null)
Modifier
.padding(start = 16.dp, end = 8.dp)
else Modifier.padding(horizontal = 8.dp)
)
) {
Row {
PreferenceItemTitle(
modifier = Modifier.weight(1f),
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,
)
}
}
trailingIcon?.let {
VerticalDivider(
modifier = Modifier
.height(32.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
thickness = 1.dp
)
trailingIcon.invoke()
}
}
}
}
@Composable
@Preview(showBackground = true)
fun PreferenceStorageHeaderPreview() {
TokushoTheme {
PreferenceStorageHeader()
}
}
@Composable
@Preview(showBackground = true)
fun PreferenceStorageItemPreview() {
TokushoTheme {
PreferenceStorageItem(title = "Saved manga", icon = Icons.Outlined.Save, total = 0L)
}
}
@Composable @Composable
@Preview @Preview
fun PreferenceItemPreview() { fun PreferenceItemPreview() {

@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.xtimms.tokusho.R import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.DismissButton
import org.xtimms.tokusho.core.updates.Updater import org.xtimms.tokusho.core.updates.Updater
import org.xtimms.tokusho.utils.system.suspendToast import org.xtimms.tokusho.utils.system.suspendToast
@ -87,11 +88,4 @@ fun UpdateDialogImpl(
Text(releaseNote) Text(releaseNote)
} }
}) })
}
@Composable
fun DismissButton(text: String = stringResource(R.string.dismiss), onClick: () -> Unit) {
TextButton(onClick = onClick) {
Text(text)
}
} }

@ -5,7 +5,13 @@ import android.os.StatFs
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import dagger.Reusable import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.xtimms.tokusho.core.cache.CacheDir
import org.xtimms.tokusho.utils.system.computeSize
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -28,6 +34,65 @@ class LocalStorageManager @Inject constructor(
return Cache(directory, maxSize) return Cache(directory, maxSize)
} }
suspend fun computeCacheSize(cache: CacheDir) = withContext(Dispatchers.IO) {
getCacheDirs(cache.dir).sumOf { it.computeSize() }
}
suspend fun computeCacheSize() = withContext(Dispatchers.IO) {
getCacheDirs().sumOf { it.computeSize() }
}
suspend fun computeAvailableSize() = runInterruptible(Dispatchers.IO) {
getAvailableStorageDirs().mapToSet { it.freeSpace }.sum()
}
fun getAvailableStorageSpace(file: File): Long {
return try {
val stat = StatFs(file.absolutePath)
stat.availableBlocksLong * stat.blockSizeLong
} catch (_: Exception) {
-1L
}
}
suspend fun clearCache(cache: CacheDir) = runInterruptible(Dispatchers.IO) {
getCacheDirs(cache.dir).forEach { it.deleteRecursively() }
}
@WorkerThread
private fun getAvailableStorageDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.filesDir, DIR_NAME)
context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
result.retainAll { it.exists() || it.mkdirs() }
return result
}
@WorkerThread
private fun getFallbackStorageDir(): File? {
return context.getExternalFilesDir(DIR_NAME) ?: File(context.filesDir, DIR_NAME).takeIf {
it.exists() || it.mkdirs()
}
}
@WorkerThread
private fun getCacheDirs(subDir: String): MutableSet<File> {
val result = LinkedHashSet<File>()
result += File(context.cacheDir, subDir)
context.externalCacheDirs.mapNotNullTo(result) {
File(it ?: return@mapNotNullTo null, subDir)
}
return result
}
@WorkerThread
private fun getCacheDirs(): MutableSet<File> {
val result = LinkedHashSet<File>()
result += context.cacheDir
context.externalCacheDirs.filterNotNullTo(result)
return result
}
private fun calculateDiskCacheSize(cacheDirectory: File): Long { private fun calculateDiskCacheSize(cacheDirectory: File): Long {
return try { return try {
val cacheDir = StatFs(cacheDirectory.absolutePath) val cacheDir = StatFs(cacheDirectory.absolutePath)

@ -6,12 +6,19 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.R
import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar import org.xtimms.tokusho.core.components.ScaffoldWithTopAppBar
import org.xtimms.tokusho.core.components.SettingItem import org.xtimms.tokusho.core.components.SettingItem
import org.xtimms.tokusho.sections.settings.storage.StorageViewModel
import org.xtimms.tokusho.utils.FileSize
const val SETTINGS_DESTINATION = "settings" const val SETTINGS_DESTINATION = "settings"
@ -20,8 +27,14 @@ fun SettingsView(
navigateBack: () -> Unit, navigateBack: () -> Unit,
navigateToAppearance: () -> Unit, navigateToAppearance: () -> Unit,
navigateToAbout: () -> Unit, navigateToAbout: () -> Unit,
navigateToAdvanced: () -> Unit navigateToAdvanced: () -> Unit,
navigateToStorage: () -> Unit
) { ) {
val context = LocalContext.current
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ScaffoldWithTopAppBar( ScaffoldWithTopAppBar(
title = stringResource(R.string.settings), title = stringResource(R.string.settings),
navigateBack = navigateBack navigateBack = navigateBack
@ -38,6 +51,32 @@ fun SettingsView(
onClick = navigateToAppearance onClick = navigateToAppearance
) )
} }
item {
val allCaches = uiState.availableSpace +
uiState.httpCacheSize +
uiState.pagesCache +
uiState.thumbnailsCache
val desc = buildString {
append((uiState.availableSpace / allCaches) * 100)
append("% used")
append(" - ")
append(
FileSize.BYTES.freeFormat(
context,
uiState.availableSpace -
uiState.httpCacheSize -
uiState.pagesCache -
uiState.thumbnailsCache
)
)
}
SettingItem(
title = stringResource(id = R.string.storage),
description = desc,
icon = Icons.Outlined.Storage,
onClick = navigateToStorage
)
}
item { item {
SettingItem( SettingItem(
title = stringResource(id = R.string.advanced), title = stringResource(id = R.string.advanced),

@ -0,0 +1,122 @@
package org.xtimms.tokusho.sections.settings.storage
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CleaningServices
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.components.ConfirmButton
import org.xtimms.tokusho.core.components.DialogCheckBoxItem
import org.xtimms.tokusho.core.components.DismissButton
import org.xtimms.tokusho.core.components.TokushoDialog
import org.xtimms.tokusho.utils.FileSize
@Composable
fun CleanDialog(
onDismissRequest: () -> Unit = {},
isPagesCacheSelected: Boolean,
isThumbnailsCacheSelected: Boolean,
isNetworkCacheSelected: Boolean,
onConfirm: (isPagesCacheSelected: Boolean, isThumbnailCacheSelected: Boolean, isNetworkCacheSelected: Boolean) -> Unit = { _, _, _ -> }
) {
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var pagesCache by remember {
mutableStateOf(isPagesCacheSelected)
}
var thumbnailsCache by remember {
mutableStateOf(isThumbnailsCacheSelected)
}
var networkCache by remember {
mutableStateOf(isNetworkCacheSelected)
}
TokushoDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
ConfirmButton {
onConfirm(pagesCache, thumbnailsCache, networkCache)
onDismissRequest()
}
},
dismissButton = {
DismissButton {
onDismissRequest()
}
},
title = {
Text(
text = stringResource(
id = R.string.free_up_space
)
)
},
icon = { Icon(imageVector = Icons.Outlined.CleaningServices, contentDescription = null) },
text = {
Column {
HorizontalDivider(modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp))
DialogCheckBoxItem(
text = stringResource(id = R.string.pages_cache),
checked = pagesCache
) {
pagesCache = !pagesCache
}
DialogCheckBoxItem(
text = stringResource(id = R.string.thumbnails_cache),
checked = thumbnailsCache
) {
thumbnailsCache = !thumbnailsCache
}
DialogCheckBoxItem(
text = stringResource(id = R.string.network_cache),
checked = networkCache
) {
networkCache = !networkCache
}
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("")
}
Text(
text = stringResource(R.string.free_up_space_summary) + " " + summary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
// style = MaterialTheme.typography.labelMedium,
)
}
})
}
@Preview
@Composable
private fun DirectoryPreferenceDialogPreview() {
CleanDialog(
onDismissRequest = {},
isPagesCacheSelected = false,
isThumbnailsCacheSelected = false,
isNetworkCacheSelected = false
)
}

@ -0,0 +1,15 @@
package org.xtimms.tokusho.sections.settings.storage
import org.xtimms.tokusho.core.base.state.UiState
data class StorageUiState(
val pagesCache: Long = -1L,
val thumbnailsCache: Long = -1L,
val availableSpace: Long = -1L,
val httpCacheSize: Long = -1L,
override val isLoading: Boolean = false,
override val message: String? = null,
) : UiState() {
override fun setLoading(value: Boolean) = copy(isLoading = value)
override fun setMessage(value: String?) = copy(message = value)
}

@ -0,0 +1,114 @@
package org.xtimms.tokusho.sections.settings.storage
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AutoStories
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
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.xtimms.tokusho.R
import org.xtimms.tokusho.core.cache.CacheDir
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
const val STORAGE_DESTINATION = "storage"
@Composable
fun StorageView(
navigateBack: () -> Unit,
) {
val viewModel: StorageViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showCleanDialog by remember { mutableStateOf(false) }
ScaffoldWithTopAppBar(
title = stringResource(R.string.storage),
navigateBack = navigateBack
) { padding ->
if (!uiState.isLoading) LazyColumn(
modifier = Modifier
.padding(padding)
) {
item {
PreferenceStorageHeader(
used = uiState.httpCacheSize + uiState.thumbnailsCache + uiState.pagesCache,
total = uiState.availableSpace
)
}
item {
PreferencesHintCard(
title = stringResource(id = R.string.free_up_space),
description = stringResource(id = R.string.free_up_space_hint),
icon = Icons.Outlined.CleaningServices
) {
showCleanDialog = true
}
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.saved_manga),
icon = Icons.Outlined.SdStorage
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.pages_cache),
icon = Icons.Outlined.AutoStories,
used = uiState.pagesCache
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.thumbnails_cache),
icon = Icons.Outlined.Image,
used = uiState.thumbnailsCache
)
}
item {
PreferenceStorageItem(
total = uiState.availableSpace,
title = stringResource(id = R.string.network_cache),
icon = Icons.Outlined.NetworkWifi,
used = uiState.httpCacheSize
)
}
} else Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
if (showCleanDialog) {
CleanDialog(
onDismissRequest = { showCleanDialog = false },
isPagesCacheSelected = false,
isNetworkCacheSelected = false,
isThumbnailsCacheSelected = false,
onConfirm = { isPagesCacheSelected, isThumbnailCacheSelected, isNetworkCacheSelected ->
if (isPagesCacheSelected) viewModel.clearCache(CacheDir.PAGES)
if (isThumbnailCacheSelected) viewModel.clearCache(CacheDir.THUMBS)
if (isNetworkCacheSelected) viewModel.clearHttpCache()
}
)
}
}

@ -0,0 +1,85 @@
package org.xtimms.tokusho.sections.settings.storage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
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)
init {
launchJob(Dispatchers.Default) {
setLoading(true)
httpCacheSize.value = runInterruptible { httpCache.size() }
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = httpCacheSize.value,
isLoading = false
)
}
}
}
fun clearCache(cache: CacheDir) {
launchJob(Dispatchers.Default) {
try {
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = httpCacheSize.value,
isLoading = false
)
}
} catch (_: Exception) {
}
}
}
fun clearHttpCache() {
launchJob(Dispatchers.Default) {
try {
val size = runInterruptible(Dispatchers.IO) {
httpCache.evictAll()
httpCache.size()
}
httpCacheSize.value = size
mutableUiState.update {
it.copy(
availableSpace = storageManager.computeAvailableSize(),
pagesCache = storageManager.computeCacheSize(CacheDir.PAGES),
thumbnailsCache = storageManager.computeCacheSize(CacheDir.THUMBS),
httpCacheSize = httpCacheSize.value,
isLoading = false
)
}
} catch (_: Exception) {
}
}
}
override val mutableUiState = MutableStateFlow(StorageUiState())
}

@ -5,8 +5,8 @@ import android.os.Build
import org.xtimms.tokusho.BuildConfig import org.xtimms.tokusho.BuildConfig
import org.xtimms.tokusho.utils.lang.withNonCancellableContext import org.xtimms.tokusho.utils.lang.withNonCancellableContext
import org.xtimms.tokusho.utils.lang.withUIContext import org.xtimms.tokusho.utils.lang.withUIContext
import org.xtimms.tokusho.utils.storage.getUriCompat
import org.xtimms.tokusho.utils.system.createFileInCacheDir import org.xtimms.tokusho.utils.system.createFileInCacheDir
import org.xtimms.tokusho.utils.system.getUriCompat
import org.xtimms.tokusho.utils.system.toShareIntent import org.xtimms.tokusho.utils.system.toShareIntent
import org.xtimms.tokusho.utils.system.toast import org.xtimms.tokusho.utils.system.toast

@ -0,0 +1,108 @@
package org.xtimms.tokusho.utils
import android.content.Context
import org.xtimms.tokusho.R
import java.text.DecimalFormat
import kotlin.math.log10
import kotlin.math.pow
enum class FileSize(private val multiplier: Int) {
BYTES(1), KILOBYTES(1024), MEGABYTES(1024 * 1024);
fun convert(amount: Long, target: FileSize): Long = amount * multiplier / target.multiplier
fun freeFormat(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_free).split('|')
if (bytes <= 0) {
return "0 ${units.first()}"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun totalFormat(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_total).split('|')
if (bytes <= 0) {
return "0 ${units.first()}"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun showUnit(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes_used).split('|')
if (bytes <= 0) {
return units.first()
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun format(context: Context, amount: Long): String {
val bytes = amount * multiplier
val units = context.getString(R.string.text_file_sizes).split('|')
if (bytes <= 0) {
return "0 ${units.first()}"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
val unit = units.getOrNull(digitGroups)
if (unit != null) {
append(' ')
append(unit)
}
}
}
fun formatWithoutUnits(amount: Long): String {
val bytes = amount * multiplier
if (bytes <= 0) {
return "0"
}
val digitGroups = (log10(bytes.toDouble()) / log10(1024.0)).toInt()
return buildString {
append(
DecimalFormat("#,##0.#").format(
bytes / 1024.0.pow(digitGroups.toDouble()),
),
)
}
}
}

@ -1,16 +0,0 @@
package org.xtimms.tokusho.utils.storage
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import org.xtimms.tokusho.BuildConfig
import java.io.File
/**
* Returns the uri of a file
*
* @param context context of application
*/
fun File.getUriCompat(context: Context): Uri {
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
}

@ -1,5 +1,26 @@
package org.xtimms.tokusho.utils.system package org.xtimms.tokusho.utils.system
import android.content.Context import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.xtimms.tokusho.BuildConfig
import java.io.File
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.walk
fun Context.getFileProvider() = "$packageName.provider" fun File.getUriCompat(context: Context): Uri {
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", this)
}
fun Context.getFileProvider() = "$packageName.provider"
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
walkCompat().sumOf { it.length() }
}
@OptIn(ExperimentalPathApi::class)
fun File.walkCompat() =
// Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() }

@ -80,4 +80,19 @@
<string name="webview_version">WebView version</string> <string name="webview_version">WebView version</string>
<string name="advanced_page">Dump crash logs, debug info</string> <string name="advanced_page">Dump crash logs, debug info</string>
<string name="device_info">Device info</string> <string name="device_info">Device info</string>
<string name="storage">Storage</string>
<string name="free_up_space">Free up space</string>
<string name="free_up_space_hint">Choose what needs to be cleared</string>
<string name="saved_manga">Saved manga</string>
<string name="pages_cache">Pages cache</string>
<string name="other_cache">Other cache</string>
<string name="thumbnails_cache">Thumbnails cache</string>
<string name="network_cache">Network cache</string>
<string name="text_file_sizes">B|kB|MB|GB|TB</string>
<string name="computing_">Computing...</string>
<string name="text_file_sizes_total">B total|kB total|MB total|GB total|TB total</string>
<string name="text_file_sizes_used">B used|kB used|MB used|GB used|TB used</string>
<string name="text_file_sizes_free">B free|kB free|MB free|GB free|TB free</string>
<string name="confirm">Confirm</string>
<string name="free_up_space_summary">Space can be cleared:</string>
</resources> </resources>
Loading…
Cancel
Save