mirror of
https://github.com/null2264/yokai.git
synced 2025-06-21 10:44:42 +00:00
feat: Unified Storage™
REF: https://mihon.app/docs/faq/storage#migrating-from-tachiyomi-v0-14-x-or-earlier
This commit is contained in:
commit
bb4858e117
66 changed files with 2956 additions and 439 deletions
|
@ -147,15 +147,9 @@ android {
|
|||
dependencies {
|
||||
// Compose
|
||||
implementation(androidx.activity.compose)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.animation)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.bundles.compose)
|
||||
debugImplementation(compose.ui.tooling)
|
||||
implementation(compose.ui.tooling.preview)
|
||||
implementation(compose.material)
|
||||
implementation(compose.material3)
|
||||
implementation(libs.compose.theme.adapter3)
|
||||
implementation(compose.icons)
|
||||
implementation(libs.accompanist.webview)
|
||||
implementation(androidx.glance.appwidget)
|
||||
|
||||
|
|
|
@ -7,11 +7,6 @@
|
|||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- Storage -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<!-- For background jobs -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
@ -37,12 +32,10 @@
|
|||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:preserveLegacyExternalStorage="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
|
|
|
@ -2,6 +2,7 @@ package dev.yokai.domain.base
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.core.preference.Preference
|
||||
import eu.kanade.tachiyomi.core.preference.PreferenceStore
|
||||
import eu.kanade.tachiyomi.core.preference.getEnum
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
|
||||
|
@ -17,4 +18,6 @@ class BasePreferences(private val preferenceStore: PreferenceStore) {
|
|||
}
|
||||
|
||||
fun displayProfile() = preferenceStore.getString("pref_display_profile_key", "")
|
||||
|
||||
fun hasShownOnboarding() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false)
|
||||
}
|
||||
|
|
|
@ -40,6 +40,8 @@ class StorageManager(
|
|||
parent.createDirectory(DOWNLOADS_PATH).also {
|
||||
DiskUtil.createNoMediaFile(it, context)
|
||||
}
|
||||
parent.createDirectory(COVERS_PATH)
|
||||
parent.createDirectory(PAGES_PATH)
|
||||
}
|
||||
_changes.send(Unit)
|
||||
}
|
||||
|
@ -66,9 +68,19 @@ class StorageManager(
|
|||
fun getLocalSourceDirectory(): UniFile? {
|
||||
return baseDir?.createDirectory(LOCAL_SOURCE_PATH)
|
||||
}
|
||||
|
||||
fun getCoversDirectory(): UniFile? {
|
||||
return baseDir?.createDirectory(COVERS_PATH)
|
||||
}
|
||||
|
||||
fun getPagesDirectory(): UniFile? {
|
||||
return baseDir?.createDirectory(PAGES_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
private const val BACKUPS_PATH = "autobackup"
|
||||
private const val BACKUPS_PATH = "backup"
|
||||
private const val AUTOMATIC_BACKUPS_PATH = "autobackup"
|
||||
private const val DOWNLOADS_PATH = "downloads"
|
||||
private const val LOCAL_SOURCE_PATH = "local"
|
||||
private const val COVERS_PATH = "covers"
|
||||
private const val PAGES_PATH = "pages"
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
package dev.yokai.presentation.component
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.os.Build
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
@Composable
|
||||
fun AppIcon(size: Dp, modifier: Modifier = Modifier) {
|
||||
ResourcesCompat.getDrawable(
|
||||
LocalContext.current.resources,
|
||||
R.mipmap.ic_launcher,
|
||||
LocalContext.current.theme
|
||||
)
|
||||
?.let { drawable ->
|
||||
val bitmap =
|
||||
Bitmap.createBitmap(
|
||||
drawable.intrinsicWidth,
|
||||
drawable.intrinsicHeight,
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = android.graphics.Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
drawable.draw(canvas)
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = modifier.requiredSize(size)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
|
||||
val res = LocalContext.current.resources
|
||||
val theme = LocalContext.current.theme
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Android O supports adaptive icons, try loading this first (even though this is least
|
||||
// likely to be the format).
|
||||
val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
|
||||
if (adaptiveIcon != null) {
|
||||
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
|
||||
} else {
|
||||
// We couldn't load the drawable as an Adaptive Icon, just use painterResource
|
||||
painterResource(id)
|
||||
}
|
||||
} else {
|
||||
// We're not on Android O or later, just use painterResource
|
||||
painterResource(id)
|
||||
}
|
||||
}
|
27
app/src/main/java/dev/yokai/presentation/component/Gap.kt
Normal file
27
app/src/main/java/dev/yokai/presentation/component/Gap.kt
Normal file
|
@ -0,0 +1,27 @@
|
|||
package dev.yokai.presentation.component
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
@Composable
|
||||
fun RowScope.Gap(width: Dp, modifier: Modifier = Modifier) {
|
||||
Spacer(modifier = modifier.width(width))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColumnScope.Gap(height: Dp, modifier: Modifier = Modifier) {
|
||||
Spacer(modifier = modifier.height(height))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.Gap(padding: Dp, modifier: Modifier = Modifier) {
|
||||
Spacer(modifier = modifier.size(padding))
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package dev.yokai.presentation.component
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.yokai.presentation.theme.Size
|
||||
|
||||
@Composable
|
||||
fun LabeledCheckbox(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(
|
||||
role = Role.Checkbox,
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
onCheckedChange(!checked)
|
||||
}
|
||||
},
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Size.small),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
Text(text = label)
|
||||
}
|
||||
}
|
332
app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt
Normal file
332
app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt
Normal file
|
@ -0,0 +1,332 @@
|
|||
package dev.yokai.presentation.component
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.themeadapter.material3.createMdc3Theme
|
||||
import dev.yokai.presentation.theme.HalfAlpha
|
||||
import dev.yokai.presentation.theme.SecondaryItemAlpha
|
||||
import dev.yokai.presentation.theme.Size
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.system.Themes
|
||||
import eu.kanade.tachiyomi.util.system.isInNightMode
|
||||
|
||||
private data class ContextTheme(
|
||||
val colorScheme: ColorScheme,
|
||||
val isThemeMatchesApp: Boolean,
|
||||
val theme: Themes,
|
||||
val isDarkTheme: Boolean,
|
||||
)
|
||||
|
||||
private fun Context.colorSchemeFromAdapter(theme: Themes, isDarkTheme: Boolean): ContextTheme {
|
||||
val configuration = Configuration(this.resources.configuration)
|
||||
configuration.uiMode =
|
||||
if (isDarkTheme) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||
val themeContext = this.createConfigurationContext(configuration)
|
||||
themeContext.setTheme(theme.styleRes)
|
||||
|
||||
@Suppress("DEPRECATION") val colorScheme =
|
||||
createMdc3Theme(
|
||||
context = themeContext,
|
||||
layoutDirection = LayoutDirection.Ltr,
|
||||
setTextColors = true,
|
||||
readTypography = false,
|
||||
)
|
||||
.colorScheme!!
|
||||
|
||||
val themeMatchesApp =
|
||||
if (this.isInNightMode()) {
|
||||
isDarkTheme
|
||||
} else {
|
||||
!isDarkTheme
|
||||
}
|
||||
|
||||
return ContextTheme(colorScheme, themeMatchesApp, theme, isDarkTheme)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemeItem(theme: Themes, isDarkTheme: Boolean, selected: Boolean, onClick: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val contextTheme = context.colorSchemeFromAdapter(theme, isDarkTheme)
|
||||
|
||||
ThemeItemNaive(contextTheme = contextTheme, selected = selected, onClick = onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeItemNaive(contextTheme: ContextTheme, selected: Boolean, onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(110.dp)) {
|
||||
ThemePreviewItem(
|
||||
contextTheme.colorScheme,
|
||||
selected,
|
||||
selectedColor = MaterialTheme.colorScheme.primary,
|
||||
contextTheme.isThemeMatchesApp,
|
||||
onClick
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = if (contextTheme.isDarkTheme) contextTheme.theme.darkNameRes else contextTheme.theme.nameRes),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemePreviewItem(
|
||||
colorScheme: ColorScheme,
|
||||
selected: Boolean,
|
||||
selectedColor: Color,
|
||||
themeMatchesApp: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val actualSelectedColor =
|
||||
when {
|
||||
themeMatchesApp && selected -> colorScheme.primary
|
||||
selected -> selectedColor.copy(alpha = HalfAlpha)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
val padding = 6
|
||||
val outer = 26
|
||||
val inner = outer - padding
|
||||
OutlinedCard(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.height(180.dp)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(outer.dp),
|
||||
border = BorderStroke(width = Size.tiny, color = actualSelectedColor),
|
||||
) {
|
||||
OutlinedCard(
|
||||
modifier = Modifier
|
||||
.height(180.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(6.dp),
|
||||
shape = RoundedCornerShape(inner.dp),
|
||||
colors = CardDefaults.outlinedCardColors(containerColor = colorScheme.background),
|
||||
border = BorderStroke(width = 1.dp, color = colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.padding(Size.small),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(15.dp)
|
||||
.weight(0.7f)
|
||||
.padding(start = Size.tiny, end = Size.small)
|
||||
.background(
|
||||
color = colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.weight(0.3f),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = stringResource(R.string.selected),
|
||||
tint = selectedColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.weight(0.6f)
|
||||
.padding(horizontal = Size.small),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(30.dp)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(30.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = Size.small, end = Size.small),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(15.dp)
|
||||
.weight(0.8f)
|
||||
.padding(end = Size.tiny)
|
||||
.background(
|
||||
color = colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(15.dp)
|
||||
.weight(0.3f)
|
||||
.background(
|
||||
color = colorScheme.secondary,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(15.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(end = Size.medium),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(0.5f)
|
||||
.padding(end = Size.tiny)
|
||||
.background(
|
||||
color = colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(0.6f)
|
||||
.background(
|
||||
color = colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
color = colorScheme.surfaceVariant,
|
||||
tonalElevation = Size.small,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(30.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = Size.extraTiny, horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(0.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(6.dp)
|
||||
.background(
|
||||
color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha),
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(0.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(6.dp)
|
||||
.background(
|
||||
color = colorScheme.secondary,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(0.2f),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(6.dp)
|
||||
.background(
|
||||
color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha),
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
|
||||
@Composable
|
||||
fun ThemeItemPreviewDark() {
|
||||
val contextTheme = ContextTheme(
|
||||
colorScheme = darkColorScheme(),
|
||||
isThemeMatchesApp = true,
|
||||
theme = Themes.DEFAULT,
|
||||
isDarkTheme = true,
|
||||
)
|
||||
Surface {
|
||||
ThemeItemNaive(contextTheme = contextTheme, selected = true) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
|
||||
@Composable
|
||||
fun ThemeItemPreviewLight() {
|
||||
val contextTheme = ContextTheme(
|
||||
colorScheme = lightColorScheme(),
|
||||
isThemeMatchesApp = true,
|
||||
theme = Themes.DEFAULT,
|
||||
isDarkTheme = false,
|
||||
)
|
||||
Surface {
|
||||
ThemeItemNaive(contextTheme = contextTheme, selected = false) {}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package dev.yokai.presentation.component
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.yokai.presentation.core.util.clickableNoIndication
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
|
||||
@Composable
|
||||
fun TrackLogoIcon(
|
||||
tracker: TrackService,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val modifier = if (onClick != null) {
|
||||
Modifier.clickableNoIndication(
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.background(color = Color(tracker.getLogoColor()), shape = MaterialTheme.shapes.medium)
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(tracker.getLogo()),
|
||||
contentDescription = stringResource(id = tracker.nameRes()),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
package dev.yokai.presentation.component.preference
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.collect.ImmutableMap
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import eu.kanade.tachiyomi.core.preference.Preference as PreferenceData
|
||||
|
||||
sealed class Preference {
|
||||
abstract val title: String
|
||||
abstract val enabled: Boolean
|
||||
|
||||
sealed class PreferenceItem<T> : Preference() {
|
||||
abstract val subtitle: String?
|
||||
abstract val icon: ImageVector?
|
||||
abstract val onValueChanged: suspend (newValue: T) -> Boolean
|
||||
|
||||
/**
|
||||
* A basic [PreferenceItem] that only displays texts.
|
||||
*/
|
||||
data class TextPreference(
|
||||
override val title: String,
|
||||
override val subtitle: String? = null,
|
||||
override val icon: ImageVector? = null,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
|
||||
|
||||
val onClick: (() -> Unit)? = null,
|
||||
) : PreferenceItem<String>()
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that provides a two-state toggleable option.
|
||||
*/
|
||||
data class SwitchPreference(
|
||||
val pref: PreferenceData<Boolean>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = null,
|
||||
override val icon: ImageVector? = null,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (newValue: Boolean) -> Boolean = { true },
|
||||
) : PreferenceItem<Boolean>()
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that provides a slider to select an integer number.
|
||||
*/
|
||||
data class SliderPreference(
|
||||
val value: Int,
|
||||
val min: Int = 0,
|
||||
val max: Int,
|
||||
override val title: String = "",
|
||||
override val subtitle: String? = null,
|
||||
override val icon: ImageVector? = null,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (newValue: Int) -> Boolean = { true },
|
||||
) : PreferenceItem<Int>()
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that displays a list of entries as a dialog.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
data class ListPreference<T>(
|
||||
val pref: PreferenceData<T>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (value: T, entries: ImmutableMap<T, String>) -> String? =
|
||||
{ v, e -> subtitle?.format(e[v]) },
|
||||
override val icon: ImageVector? = null,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (newValue: T) -> Boolean = { true },
|
||||
|
||||
val entries: ImmutableMap<T, String>,
|
||||
) : PreferenceItem<T>() {
|
||||
internal fun internalSet(newValue: Any) = pref.set(newValue as T)
|
||||
internal suspend fun internalOnValueChanged(newValue: Any) = onValueChanged(newValue as T)
|
||||
|
||||
@Composable
|
||||
internal fun internalSubtitleProvider(value: Any?, entries: ImmutableMap<out Any?, String>) =
|
||||
subtitleProvider(value as T, entries as ImmutableMap<T, String>)
|
||||
}
|
||||
|
||||
/**
|
||||
* [ListPreference] but with no connection to a [PreferenceData]
|
||||
*/
|
||||
data class BasicListPreference(
|
||||
val value: String,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (value: String, entries: ImmutableMap<String, String>) -> String? =
|
||||
{ v, e -> subtitle?.format(e[v]) },
|
||||
override val icon: ImageVector? = null,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
|
||||
|
||||
val entries: ImmutableMap<String, String>,
|
||||
) : PreferenceItem<String>()
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that displays a list of entries as a dialog.
|
||||
* Multiple entries can be selected at the same time.
|
||||
*/
|
||||
data class MultiSelectListPreference(
|
||||
val pref: PreferenceData<Set<String>>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
val subtitleProvider: @Composable (
|
||||
value: Set<String>,
|
||||
entries: ImmutableMap<String, String>,
|
||||
) -> String? = { v, e ->
|
||||
val combined = remember(v) {
|
||||
v.map { e[it] }
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.joinToString()
|
||||
} ?: stringResource(R.string.none)
|
||||
subtitle?.format(combined)
|
||||
},
|
||||
override val icon: ImageVector? = null,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (newValue: Set<String>) -> Boolean = { true },
|
||||
|
||||
val entries: ImmutableMap<String, String>,
|
||||
) : PreferenceItem<Set<String>>()
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] that shows a EditText in the dialog.
|
||||
*/
|
||||
data class EditTextPreference(
|
||||
val pref: PreferenceData<String>,
|
||||
override val title: String,
|
||||
override val subtitle: String? = "%s",
|
||||
override val icon: ImageVector? = null,
|
||||
override val enabled: Boolean = true,
|
||||
override val onValueChanged: suspend (newValue: String) -> Boolean = { true },
|
||||
) : PreferenceItem<String>()
|
||||
|
||||
/**
|
||||
* A [PreferenceItem] for individual tracker.
|
||||
*/
|
||||
data class TrackerPreference(
|
||||
val tracker: TrackService,
|
||||
override val title: String,
|
||||
val login: () -> Unit,
|
||||
val logout: () -> Unit,
|
||||
) : PreferenceItem<String>() {
|
||||
override val enabled: Boolean = true
|
||||
override val subtitle: String? = null
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }
|
||||
}
|
||||
|
||||
data class InfoPreference(
|
||||
override val title: String,
|
||||
) : PreferenceItem<String>() {
|
||||
override val enabled: Boolean = true
|
||||
override val subtitle: String? = null
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }
|
||||
}
|
||||
|
||||
data class CustomPreference(
|
||||
override val title: String,
|
||||
val content: @Composable (PreferenceItem<String>) -> Unit,
|
||||
) : PreferenceItem<String>() {
|
||||
override val enabled: Boolean = true
|
||||
override val subtitle: String? = null
|
||||
override val icon: ImageVector? = null
|
||||
override val onValueChanged: suspend (newValue: String) -> Boolean = { true }
|
||||
}
|
||||
}
|
||||
|
||||
data class PreferenceGroup(
|
||||
override val title: String,
|
||||
override val enabled: Boolean = true,
|
||||
|
||||
val preferenceItems: ImmutableList<PreferenceItem<out Any>>,
|
||||
) : Preference()
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
package dev.yokai.presentation.component.preference
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.structuralEqualityPolicy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.text.Text
|
||||
import dev.yokai.presentation.component.preference.widget.EditTextPreferenceWidget
|
||||
import dev.yokai.presentation.component.preference.widget.InfoWidget
|
||||
import dev.yokai.presentation.component.preference.widget.ListPreferenceWidget
|
||||
import dev.yokai.presentation.component.preference.widget.MultiSelectListPreferenceWidget
|
||||
import dev.yokai.presentation.component.preference.widget.SwitchPreferenceWidget
|
||||
import dev.yokai.presentation.component.preference.widget.TextPreferenceWidget
|
||||
import dev.yokai.presentation.component.preference.widget.TrackingPreferenceWidget
|
||||
import eu.kanade.tachiyomi.core.preference.collectAsState
|
||||
import eu.kanade.tachiyomi.data.track.TrackPreferences
|
||||
import kotlinx.coroutines.launch
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false }
|
||||
val LocalPreferenceMinHeight = compositionLocalOf(structuralEqualityPolicy()) { 56.dp }
|
||||
|
||||
@Composable
|
||||
fun StatusWrapper(
|
||||
item: Preference.PreferenceItem<*>,
|
||||
highlightKey: String?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val enabled = item.enabled
|
||||
val highlighted = item.title == highlightKey
|
||||
AnimatedVisibility(
|
||||
visible = enabled,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
content = {
|
||||
CompositionLocalProvider(
|
||||
LocalPreferenceHighlighted provides highlighted,
|
||||
content = content,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PreferenceItem(
|
||||
item: Preference.PreferenceItem<*>,
|
||||
highlightKey: String?,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
StatusWrapper(
|
||||
item = item,
|
||||
highlightKey = highlightKey,
|
||||
) {
|
||||
when (item) {
|
||||
is Preference.PreferenceItem.SwitchPreference -> {
|
||||
val value by item.pref.collectAsState()
|
||||
SwitchPreferenceWidget(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
icon = item.icon,
|
||||
checked = value,
|
||||
onCheckedChanged = { newValue ->
|
||||
scope.launch {
|
||||
if (item.onValueChanged(newValue)) {
|
||||
item.pref.set(newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.SliderPreference -> {
|
||||
// TODO: use different composable?
|
||||
// FIXME: Add the actual thing
|
||||
Text(text = "Hello World")
|
||||
/*
|
||||
SliderItem(
|
||||
label = item.title,
|
||||
min = item.min,
|
||||
max = item.max,
|
||||
value = item.value,
|
||||
valueText = item.subtitle.takeUnless { it.isNullOrEmpty() } ?: item.value.toString(),
|
||||
onChange = {
|
||||
scope.launch {
|
||||
item.onValueChanged(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
*/
|
||||
}
|
||||
is Preference.PreferenceItem.ListPreference<*> -> {
|
||||
val value by item.pref.collectAsState()
|
||||
ListPreferenceWidget(
|
||||
value = value,
|
||||
title = item.title,
|
||||
subtitle = item.internalSubtitleProvider(value, item.entries),
|
||||
icon = item.icon,
|
||||
entries = item.entries,
|
||||
onValueChange = { newValue ->
|
||||
scope.launch {
|
||||
if (item.internalOnValueChanged(newValue!!)) {
|
||||
item.internalSet(newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.BasicListPreference -> {
|
||||
ListPreferenceWidget(
|
||||
value = item.value,
|
||||
title = item.title,
|
||||
subtitle = item.subtitleProvider(item.value, item.entries),
|
||||
icon = item.icon,
|
||||
entries = item.entries,
|
||||
onValueChange = { scope.launch { item.onValueChanged(it) } },
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.MultiSelectListPreference -> {
|
||||
val values by item.pref.collectAsState()
|
||||
MultiSelectListPreferenceWidget(
|
||||
preference = item,
|
||||
values = values,
|
||||
onValuesChange = { newValues ->
|
||||
scope.launch {
|
||||
if (item.onValueChanged(newValues)) {
|
||||
item.pref.set(newValues.toMutableSet())
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.TextPreference -> {
|
||||
TextPreferenceWidget(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
icon = item.icon,
|
||||
onPreferenceClick = item.onClick,
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.EditTextPreference -> {
|
||||
val values by item.pref.collectAsState()
|
||||
EditTextPreferenceWidget(
|
||||
title = item.title,
|
||||
subtitle = item.subtitle,
|
||||
icon = item.icon,
|
||||
value = values,
|
||||
onConfirm = {
|
||||
val accepted = item.onValueChanged(it)
|
||||
if (accepted) item.pref.set(it)
|
||||
accepted
|
||||
},
|
||||
)
|
||||
}
|
||||
is Preference.PreferenceItem.TrackerPreference -> {
|
||||
val uName by Injekt.get<TrackPreferences>()
|
||||
.trackUsername(item.tracker)
|
||||
.collectAsState()
|
||||
item.tracker.run {
|
||||
TrackingPreferenceWidget(
|
||||
tracker = this,
|
||||
checked = uName.isNotEmpty(),
|
||||
onClick = { if (isLogged) item.logout() else item.login() },
|
||||
)
|
||||
}
|
||||
}
|
||||
is Preference.PreferenceItem.InfoPreference -> {
|
||||
InfoWidget(text = item.title)
|
||||
}
|
||||
is Preference.PreferenceItem.CustomPreference -> {
|
||||
item.content(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.StartOffset
|
||||
import androidx.compose.animation.core.StartOffsetType
|
||||
import androidx.compose.animation.core.repeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.yokai.presentation.component.preference.LocalPreferenceHighlighted
|
||||
import dev.yokai.presentation.component.preference.LocalPreferenceMinHeight
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Composable
|
||||
internal fun BasePreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
subcomponent: @Composable (ColumnScope.() -> Unit)? = null,
|
||||
icon: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
widget: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val highlighted = LocalPreferenceHighlighted.current
|
||||
val minHeight = LocalPreferenceMinHeight.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.highlightBackground(highlighted)
|
||||
.sizeIn(minHeight = minHeight)
|
||||
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Box(
|
||||
modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
|
||||
content = { icon() },
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = PrefsVerticalPadding),
|
||||
) {
|
||||
if (!title.isNullOrBlank()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
|
||||
text = title,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = TitleFontSize,
|
||||
)
|
||||
}
|
||||
subcomponent?.invoke(this)
|
||||
}
|
||||
if (widget != null) {
|
||||
Box(
|
||||
modifier = Modifier.padding(end = PrefsHorizontalPadding),
|
||||
content = { widget() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = composed {
|
||||
var highlightFlag by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
if (highlighted) {
|
||||
highlightFlag = true
|
||||
delay(3.seconds)
|
||||
highlightFlag = false
|
||||
}
|
||||
}
|
||||
val highlight by animateColorAsState(
|
||||
targetValue = if (highlightFlag) {
|
||||
MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
animationSpec = if (highlightFlag) {
|
||||
repeatable(
|
||||
iterations = 5,
|
||||
animation = tween(durationMillis = 200),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
initialStartOffset = StartOffset(
|
||||
offsetMillis = 600,
|
||||
offsetType = StartOffsetType.Delay,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
tween(200)
|
||||
},
|
||||
label = "highlight",
|
||||
)
|
||||
this.background(color = highlight)
|
||||
}
|
||||
|
||||
internal val TrailingWidgetBuffer = 16.dp
|
||||
internal val PrefsHorizontalPadding = 16.dp
|
||||
internal val PrefsVerticalPadding = 16.dp
|
||||
internal val TitleFontSize = 16.sp
|
|
@ -0,0 +1,97 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import eu.kanade.tachiyomi.R
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@Composable
|
||||
fun EditTextPreferenceWidget(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector?,
|
||||
value: String,
|
||||
onConfirm: suspend (String) -> Boolean,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = title,
|
||||
subtitle = subtitle?.format(value),
|
||||
icon = icon,
|
||||
onPreferenceClick = { isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val onDismissRequest = { isDialogShown = false }
|
||||
var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(value))
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { textFieldValue = it },
|
||||
trailingIcon = {
|
||||
if (textFieldValue.text.isBlank()) {
|
||||
Icon(imageVector = Icons.Filled.Error, contentDescription = null)
|
||||
} else {
|
||||
IconButton(onClick = { textFieldValue = TextFieldValue("") }) {
|
||||
Icon(imageVector = Icons.Filled.Cancel, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
isError = textFieldValue.text.isBlank(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = textFieldValue.text != value && textFieldValue.text.isNotBlank(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (onConfirm(textFieldValue.text)) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.yokai.presentation.core.util.secondaryItemAlpha
|
||||
import dev.yokai.presentation.theme.Size
|
||||
|
||||
@Composable
|
||||
internal fun InfoWidget(text: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
horizontal = PrefsHorizontalPadding,
|
||||
vertical = Size.medium,
|
||||
)
|
||||
.secondaryItemAlpha(),
|
||||
verticalArrangement = Arrangement.spacedBy(Size.medium),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
||||
@Composable
|
||||
fun <T> ListPreferenceWidget(
|
||||
value: T,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: ImageVector?,
|
||||
entries: Map<out T, String>,
|
||||
onValueChange: (T) -> Unit,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
icon = icon,
|
||||
onPreferenceClick = { isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Box {
|
||||
val state = rememberLazyListState()
|
||||
LazyColumn(state = state) {
|
||||
entries.forEach { current ->
|
||||
val isSelected = value == current.key
|
||||
item {
|
||||
DialogRow(
|
||||
label = current.value,
|
||||
isSelected = isSelected,
|
||||
onSelected = {
|
||||
onValueChange(current.key!!)
|
||||
isDialogShown = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
|
||||
if (state.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogRow(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.selectable(
|
||||
selected = isSelected,
|
||||
onClick = { if (!isSelected) onSelected() },
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.minimumInteractiveComponentSize(),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = null,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge.merge(),
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.runtime.toMutableStateList
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import dev.yokai.presentation.component.LabeledCheckbox
|
||||
import dev.yokai.presentation.component.preference.Preference
|
||||
|
||||
@Composable
|
||||
fun MultiSelectListPreferenceWidget(
|
||||
preference: Preference.PreferenceItem.MultiSelectListPreference,
|
||||
values: Set<String>,
|
||||
onValuesChange: (Set<String>) -> Unit,
|
||||
) {
|
||||
var isDialogShown by remember { mutableStateOf(false) }
|
||||
|
||||
TextPreferenceWidget(
|
||||
title = preference.title,
|
||||
subtitle = preference.subtitleProvider(values, preference.entries),
|
||||
icon = preference.icon,
|
||||
onPreferenceClick = { isDialogShown = true },
|
||||
)
|
||||
|
||||
if (isDialogShown) {
|
||||
val selected = remember {
|
||||
preference.entries.keys
|
||||
.filter { values.contains(it) }
|
||||
.toMutableStateList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { isDialogShown = false },
|
||||
title = { Text(text = preference.title) },
|
||||
text = {
|
||||
LazyColumn {
|
||||
preference.entries.forEach { current ->
|
||||
item {
|
||||
val isSelected = selected.contains(current.key)
|
||||
LabeledCheckbox(
|
||||
label = current.value,
|
||||
checked = isSelected,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
selected.add(current.key)
|
||||
} else {
|
||||
selected.remove(current.key)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true,
|
||||
),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onValuesChange(selected.toMutableSet())
|
||||
isDialogShown = false
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { isDialogShown = false }) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PreferenceGroupHeader(title: String) {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp, top = 14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
fun SwitchPreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
checked: Boolean = false,
|
||||
onCheckedChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
TextPreferenceWidget(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
subtitle = subtitle,
|
||||
icon = icon,
|
||||
widget = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
modifier = Modifier.padding(start = TrailingWidgetBuffer),
|
||||
)
|
||||
},
|
||||
onPreferenceClick = { onCheckedChanged(!checked) },
|
||||
)
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import dev.yokai.presentation.core.util.secondaryItemAlpha
|
||||
|
||||
@Composable
|
||||
fun TextPreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
subtitle: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
widget: @Composable (() -> Unit)? = null,
|
||||
onPreferenceClick: (() -> Unit)? = null,
|
||||
) {
|
||||
BasePreferenceWidget(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
subcomponent = if (!subtitle.isNullOrBlank()) {
|
||||
{
|
||||
Text(
|
||||
text = subtitle,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = PrefsHorizontalPadding)
|
||||
.secondaryItemAlpha(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
icon = if (icon != null) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
tint = iconTint,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onPreferenceClick,
|
||||
widget = widget,
|
||||
)
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Done
|
||||
import androidx.compose.material3.Icon
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.yokai.presentation.component.TrackLogoIcon
|
||||
import dev.yokai.presentation.component.preference.LocalPreferenceHighlighted
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.appwidget.util.stringResource
|
||||
import eu.kanade.tachiyomi.data.track.TrackService
|
||||
|
||||
@Composable
|
||||
fun TrackingPreferenceWidget(
|
||||
modifier: Modifier = Modifier,
|
||||
tracker: TrackService,
|
||||
checked: Boolean,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val highlighted = LocalPreferenceHighlighted.current
|
||||
Box(modifier = Modifier.highlightBackground(highlighted)) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TrackLogoIcon(tracker)
|
||||
Text(
|
||||
text = stringResource(id = tracker.nameRes()),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp),
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontSize = TitleFontSize,
|
||||
)
|
||||
if (checked) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Done,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.size(32.dp),
|
||||
tint = Color(0xFF4CAF50),
|
||||
contentDescription = stringResource(R.string.successfully_logged_in),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
package dev.yokai.presentation.component.preference.widget
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CheckBox
|
||||
import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank
|
||||
import androidx.compose.material.icons.rounded.DisabledByDefault
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
private enum class State {
|
||||
CHECKED, INVERSED, UNCHECKED
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> TriStateListDialog(
|
||||
title: String,
|
||||
message: String? = null,
|
||||
items: List<T>,
|
||||
initialChecked: List<T>,
|
||||
initialInversed: List<T>,
|
||||
itemLabel: @Composable (T) -> String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onValueChanged: (newIncluded: List<T>, newExcluded: List<T>) -> Unit,
|
||||
) {
|
||||
val selected = remember {
|
||||
items
|
||||
.map {
|
||||
when (it) {
|
||||
in initialChecked -> State.CHECKED
|
||||
in initialInversed -> State.INVERSED
|
||||
else -> State.UNCHECKED
|
||||
}
|
||||
}
|
||||
.toMutableStateList()
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = { Text(text = title) },
|
||||
text = {
|
||||
Column {
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
val listState = rememberLazyListState()
|
||||
LazyColumn(state = listState) {
|
||||
itemsIndexed(items = items) { index, item ->
|
||||
val state = selected[index]
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable {
|
||||
selected[index] = when (state) {
|
||||
State.UNCHECKED -> State.CHECKED
|
||||
State.CHECKED -> State.INVERSED
|
||||
State.INVERSED -> State.UNCHECKED
|
||||
}
|
||||
}
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.padding(end = 20.dp),
|
||||
imageVector = when (state) {
|
||||
State.UNCHECKED -> Icons.Rounded.CheckBoxOutlineBlank
|
||||
State.CHECKED -> Icons.Rounded.CheckBox
|
||||
State.INVERSED -> Icons.Rounded.DisabledByDefault
|
||||
},
|
||||
tint = if (state == State.UNCHECKED) {
|
||||
LocalContentColor.current
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
contentDescription = stringResource(
|
||||
when (state) {
|
||||
State.UNCHECKED -> R.string.not_selected
|
||||
State.CHECKED -> R.string.selected
|
||||
State.INVERSED -> R.string.disabled
|
||||
},
|
||||
),
|
||||
)
|
||||
Text(text = itemLabel(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (listState.canScrollBackward) HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter))
|
||||
if (listState.canScrollForward) HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val included = items.mapIndexedNotNull { index, category ->
|
||||
if (selected[index] == State.CHECKED) category else null
|
||||
}
|
||||
val excluded = items.mapIndexedNotNull { index, category ->
|
||||
if (selected[index] == State.INVERSED) category else null
|
||||
}
|
||||
onValueChanged(included, excluded)
|
||||
},
|
||||
) {
|
||||
Text(text = stringResource(android.R.string.ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package dev.yokai.presentation.core.util
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import dev.yokai.presentation.theme.SecondaryItemAlpha
|
||||
|
||||
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
|
||||
|
||||
fun Modifier.clickableNoIndication(
|
||||
interactionSource: MutableInteractionSource,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) = this.combinedClickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
)
|
|
@ -4,11 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExtensionOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
|
|
|
@ -0,0 +1,184 @@
|
|||
package dev.yokai.presentation.onboarding
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.RocketLaunch
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import dev.yokai.presentation.core.util.secondaryItemAlpha
|
||||
import dev.yokai.presentation.theme.Size
|
||||
|
||||
@Composable
|
||||
fun InfoScreen(
|
||||
icon: ImageVector,
|
||||
headingText: String,
|
||||
subtitleText: String,
|
||||
tint: Color = MaterialTheme.colorScheme.primary,
|
||||
acceptText: String,
|
||||
onAcceptClick: () -> Unit,
|
||||
canAccept: Boolean,
|
||||
rejectText: String? = null,
|
||||
onRejectClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
InfoScreen(
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = Size.small)
|
||||
.size(Size.huge),
|
||||
tint = tint,
|
||||
)
|
||||
},
|
||||
headingText = headingText,
|
||||
subtitleText = subtitleText,
|
||||
acceptText = acceptText,
|
||||
onAcceptClick = onAcceptClick,
|
||||
canAccept = canAccept,
|
||||
rejectText = rejectText,
|
||||
onRejectClick = onRejectClick,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoScreen(
|
||||
icon: @Composable () -> Unit,
|
||||
headingText: String,
|
||||
subtitleText: String,
|
||||
tint: Color = MaterialTheme.colorScheme.primary,
|
||||
acceptText: String,
|
||||
onAcceptClick: () -> Unit,
|
||||
canAccept: Boolean,
|
||||
rejectText: String? = null,
|
||||
onRejectClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
val strokeWidth = Dp.Hairline
|
||||
val borderColor = MaterialTheme.colorScheme.outline
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.drawBehind {
|
||||
drawLine(
|
||||
borderColor,
|
||||
Offset(0f, 0f),
|
||||
Offset(size.width, 0f),
|
||||
strokeWidth.value,
|
||||
)
|
||||
}
|
||||
.windowInsetsPadding(NavigationBarDefaults.windowInsets)
|
||||
.padding(
|
||||
horizontal = Size.medium,
|
||||
vertical = Size.small,
|
||||
),
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = canAccept,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = tint),
|
||||
onClick = onAcceptClick,
|
||||
) {
|
||||
Text(text = acceptText)
|
||||
}
|
||||
if (rejectText != null && onRejectClick != null) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border =
|
||||
BorderStroke(
|
||||
width = Size.extraExtraTiny,
|
||||
color = tint,
|
||||
),
|
||||
onClick = onRejectClick,
|
||||
) {
|
||||
Text(text = rejectText)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
// Status bar scrim
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.secondaryItemAlpha()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxWidth()
|
||||
.height(paddingValues.calculateTopPadding()),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.fillMaxWidth()
|
||||
.padding(paddingValues)
|
||||
.padding(top = Size.huge)
|
||||
.padding(horizontal = Size.medium),
|
||||
) {
|
||||
icon()
|
||||
|
||||
Text(
|
||||
text = headingText,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
)
|
||||
Text(
|
||||
text = subtitleText,
|
||||
modifier = Modifier
|
||||
.secondaryItemAlpha()
|
||||
.padding(vertical = Size.small),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun InfoScreenPreview() {
|
||||
InfoScreen(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
headingText = "Welcome!",
|
||||
subtitleText = "Subtitle",
|
||||
acceptText = "Accept",
|
||||
onAcceptClick = {},
|
||||
canAccept = true,
|
||||
rejectText = "Reject",
|
||||
onRejectClick = {},
|
||||
) {
|
||||
Text(text = "Hello World")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package dev.yokai.presentation.onboarding
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import dev.yokai.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.core.preference.collectAsState
|
||||
import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class OnboardingController :
|
||||
BaseComposeController() {
|
||||
|
||||
val basePreferences by injectLazy<BasePreferences>()
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
|
||||
val hasShownOnboarding by basePreferences.hasShownOnboarding().collectAsState()
|
||||
|
||||
val finishOnboarding: () -> Unit = {
|
||||
basePreferences.hasShownOnboarding().set(true)
|
||||
router.popCurrentController()
|
||||
}
|
||||
|
||||
BackHandler(
|
||||
enabled = !hasShownOnboarding,
|
||||
onBack = {
|
||||
// Prevent exiting if onboarding hasn't been completed
|
||||
},
|
||||
)
|
||||
|
||||
OnboardingScreen(
|
||||
onComplete = finishOnboarding
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
package dev.yokai.presentation.onboarding
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.RocketLaunch
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import dev.yokai.presentation.onboarding.steps.PermissionStep
|
||||
import dev.yokai.presentation.onboarding.steps.StorageStep
|
||||
import dev.yokai.presentation.onboarding.steps.ThemeStep
|
||||
import dev.yokai.presentation.theme.Size
|
||||
import eu.kanade.tachiyomi.R
|
||||
import soup.compose.material.motion.animation.materialSharedAxisX
|
||||
import soup.compose.material.motion.animation.rememberSlideDistance
|
||||
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onComplete: () -> Unit = {}
|
||||
) {
|
||||
val slideDistance = rememberSlideDistance()
|
||||
|
||||
var currentStep by rememberSaveable { mutableIntStateOf(0) }
|
||||
val steps = remember {
|
||||
listOf(
|
||||
ThemeStep(),
|
||||
StorageStep(),
|
||||
PermissionStep(),
|
||||
)
|
||||
}
|
||||
val isLastStep = currentStep == steps.lastIndex
|
||||
|
||||
BackHandler(enabled = currentStep != 0, onBack = { currentStep-- })
|
||||
|
||||
InfoScreen(
|
||||
icon = Icons.Outlined.RocketLaunch,
|
||||
headingText = stringResource(R.string.onboarding_heading),
|
||||
subtitleText = stringResource(R.string.onboarding_description),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
acceptText = stringResource(
|
||||
if (isLastStep)
|
||||
R.string.onboarding_finish
|
||||
else {
|
||||
R.string.next
|
||||
}
|
||||
),
|
||||
canAccept = steps[currentStep].isComplete,
|
||||
onAcceptClick = {
|
||||
if (isLastStep) {
|
||||
onComplete()
|
||||
} else {
|
||||
currentStep++
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = Size.small)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = currentStep,
|
||||
transitionSpec = {
|
||||
materialSharedAxisX(
|
||||
forward = targetState > initialState,
|
||||
slideDistance = slideDistance,
|
||||
)
|
||||
},
|
||||
label = "stepContent",
|
||||
) { step ->
|
||||
steps[step].Content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package dev.yokai.presentation.onboarding.steps
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
internal interface OnboardingStep {
|
||||
|
||||
val isComplete: Boolean
|
||||
|
||||
@Composable
|
||||
fun Content()
|
||||
}
|
|
@ -0,0 +1,199 @@
|
|||
package dev.yokai.presentation.onboarding.steps
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import dev.yokai.presentation.component.Gap
|
||||
import dev.yokai.presentation.theme.Size
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
||||
internal class PermissionStep : OnboardingStep {
|
||||
|
||||
private var installGranted by mutableStateOf(false)
|
||||
|
||||
override val isComplete: Boolean
|
||||
get() = installGranted
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
var notificationGranted by remember {
|
||||
mutableStateOf(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var batteryGranted by remember {
|
||||
mutableStateOf(
|
||||
context
|
||||
.getSystemService<PowerManager>()!!
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner.lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
installGranted =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Settings.Secure.getInt(
|
||||
context.contentResolver,
|
||||
Settings.Secure.INSTALL_NON_MARKET_APPS
|
||||
) != 0
|
||||
}
|
||||
batteryGranted =
|
||||
context
|
||||
.getSystemService<PowerManager>()!!
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = Size.medium),
|
||||
) {
|
||||
SectionHeader(stringResource(R.string.onboarding_permission_type_required))
|
||||
|
||||
PermissionItem(
|
||||
title = stringResource(R.string.onboarding_permission_install_apps),
|
||||
subtitle = stringResource(R.string.onboarding_permission_install_apps_description),
|
||||
granted = installGranted,
|
||||
onButtonClick = {
|
||||
val intent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
} else {
|
||||
Intent(Settings.ACTION_SECURITY_SETTINGS)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
|
||||
Gap(Size.medium)
|
||||
|
||||
SectionHeader(stringResource(R.string.onboarding_permission_type_optional))
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permissionRequester =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
onResult = { bool -> notificationGranted = bool },
|
||||
)
|
||||
PermissionItem(
|
||||
title = stringResource(R.string.onboarding_permission_notifications),
|
||||
subtitle =
|
||||
stringResource(R.string.onboarding_permission_notifications_description),
|
||||
granted = notificationGranted,
|
||||
onButtonClick = {
|
||||
permissionRequester.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
PermissionItem(
|
||||
title = stringResource(R.string.onboarding_permission_ignore_battery_opts),
|
||||
subtitle =
|
||||
stringResource(R.string.onboarding_permission_ignore_battery_opts_description),
|
||||
granted = batteryGranted,
|
||||
onButtonClick = {
|
||||
@SuppressLint("BatteryLife")
|
||||
val intent =
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = modifier.padding(horizontal = Size.medium),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionItem(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
granted: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onButtonClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier,
|
||||
headlineContent = { Text(text = title) },
|
||||
supportingContent = { Text(text = subtitle) },
|
||||
trailingContent = {
|
||||
OutlinedButton(
|
||||
enabled = !granted,
|
||||
onClick = onButtonClick,
|
||||
) {
|
||||
if (granted) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(stringResource(R.string.onboarding_permission_action_grant))
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
package dev.yokai.presentation.onboarding.steps
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.net.toUri
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StoragePreferences
|
||||
import dev.yokai.presentation.theme.Size
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.core.preference.Preference
|
||||
import eu.kanade.tachiyomi.core.preference.collectAsState
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
internal class StorageStep : OnboardingStep {
|
||||
|
||||
private val storagePref: StoragePreferences by injectLazy()
|
||||
|
||||
private var _isComplete by mutableStateOf(false)
|
||||
|
||||
override val isComplete: Boolean
|
||||
get() = _isComplete
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val context = LocalContext.current
|
||||
val handler = LocalUriHandler.current
|
||||
|
||||
val pickStorageLocation = storageLocationPicker(storagePref.baseStorageDirectory())
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(Size.medium).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(Size.small),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.onboarding_storage_info,
|
||||
stringResource(R.string.app_name),
|
||||
storageLocationText(storagePref.baseStorageDirectory()),
|
||||
),
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
try {
|
||||
pickStorageLocation.launch(null)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
context.toast(R.string.file_picker_error)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_storage_action_select))
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = Size.small),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.onboarding_storage_help_info))
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
handler.openUri(
|
||||
"https://mihon.app/docs/faq/storage#migrating-from-tachiyomi-v0-14-x-or-earlier"
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_storage_help_action))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
storagePref.baseStorageDirectory().changes().collectLatest {
|
||||
_isComplete = storagePref.baseStorageDirectory().isSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun storageLocationPicker(
|
||||
storageDirPref: Preference<String>,
|
||||
): ManagedActivityResultLauncher<Uri?, Uri?> {
|
||||
val context = LocalContext.current
|
||||
|
||||
return rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val flags =
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
|
||||
UniFile.fromUri(context, uri)?.let { storageDirPref.set(it.uri.toString()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun storageLocationText(
|
||||
storageDirPref: Preference<String>,
|
||||
): String {
|
||||
val context = LocalContext.current
|
||||
val storageDir by storageDirPref.collectAsState()
|
||||
|
||||
if (storageDir == storageDirPref.defaultValue()) {
|
||||
return stringResource(R.string.no_location_set)
|
||||
}
|
||||
|
||||
return remember(storageDir) {
|
||||
val file = UniFile.fromUri(context, storageDir.toUri())
|
||||
file?.filePath
|
||||
} ?: stringResource(R.string.invalid_location, storageDir)
|
||||
}
|
|
@ -0,0 +1,214 @@
|
|||
package dev.yokai.presentation.onboarding.steps
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import dev.yokai.presentation.component.ThemeItem
|
||||
import dev.yokai.presentation.theme.Size
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.core.preference.collectAsState
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.system.Themes
|
||||
import eu.kanade.tachiyomi.util.system.appDelegateNightMode
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
internal class ThemeStep : OnboardingStep {
|
||||
override val isComplete: Boolean = true
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val context = LocalContext.current
|
||||
|
||||
val nightModePreference = preferences.nightMode()
|
||||
|
||||
val nightMode by nightModePreference.collectAsState()
|
||||
|
||||
val followingSystemTheme by remember(nightMode) {
|
||||
derivedStateOf { nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM }
|
||||
}
|
||||
|
||||
val darkAppTheme by preferences.darkTheme().collectAsState()
|
||||
val lightAppTheme by preferences.lightTheme().collectAsState()
|
||||
val supportsDynamic = DynamicColors.isDynamicColorAvailable()
|
||||
|
||||
Themes.entries
|
||||
.filter {
|
||||
(!it.isDarkTheme || it.followsSystem) &&
|
||||
(it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
val lightThemes by remember {
|
||||
derivedStateOf {
|
||||
Themes.entries
|
||||
.filter {
|
||||
(!it.isDarkTheme || it.followsSystem) &&
|
||||
(it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic)
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
val darkThemes by remember {
|
||||
derivedStateOf {
|
||||
Themes.entries
|
||||
.filter {
|
||||
(it.isDarkTheme || it.followsSystem) &&
|
||||
(it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic)
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(Size.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(Size.medium),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.follow_system_theme))
|
||||
Switch(
|
||||
checked = nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
onCheckedChange = {
|
||||
when (it) {
|
||||
true -> {
|
||||
preferences
|
||||
.nightMode()
|
||||
.set(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
(context as? Activity)?.let { activity ->
|
||||
ActivityCompat.recreate(activity)
|
||||
}
|
||||
}
|
||||
false -> preferences.nightMode().set(context.appDelegateNightMode())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(Size.medium)
|
||||
) {
|
||||
lightThemes.forEach { theme ->
|
||||
val isSelected =
|
||||
remember(darkAppTheme, lightAppTheme, nightMode) {
|
||||
isSelected(theme, false, darkAppTheme, lightAppTheme, nightMode)
|
||||
}
|
||||
ThemeItem(
|
||||
theme = theme,
|
||||
isDarkTheme = false,
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
themeClicked(
|
||||
theme,
|
||||
context,
|
||||
isSelected = isSelected,
|
||||
followingSystemTheme = followingSystemTheme,
|
||||
isDarkTheme = false
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(Size.medium)
|
||||
) {
|
||||
darkThemes.forEach { theme ->
|
||||
val isSelected =
|
||||
remember(darkAppTheme, lightAppTheme, nightMode) {
|
||||
isSelected(theme, true, darkAppTheme, lightAppTheme, nightMode)
|
||||
}
|
||||
ThemeItem(
|
||||
theme = theme,
|
||||
isDarkTheme = true,
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
themeClicked(
|
||||
theme,
|
||||
context,
|
||||
isSelected = isSelected,
|
||||
followingSystemTheme = followingSystemTheme,
|
||||
isDarkTheme = true
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSelected(
|
||||
theme: Themes,
|
||||
isDarkTheme: Boolean,
|
||||
darkAppTheme: Themes,
|
||||
lightAppTheme: Themes,
|
||||
nightMode: Int
|
||||
): Boolean {
|
||||
return when (nightMode) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> darkAppTheme == theme && isDarkTheme
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> lightAppTheme == theme && !isDarkTheme
|
||||
else ->
|
||||
(darkAppTheme == theme && isDarkTheme) || (lightAppTheme == theme && !isDarkTheme)
|
||||
}
|
||||
}
|
||||
|
||||
private fun themeClicked(
|
||||
theme: Themes,
|
||||
context: Context,
|
||||
isSelected: Boolean,
|
||||
followingSystemTheme: Boolean,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val nightMode =
|
||||
when (isDarkTheme) {
|
||||
true -> {
|
||||
preferences.darkTheme().set(theme)
|
||||
AppCompatDelegate.MODE_NIGHT_YES
|
||||
}
|
||||
false -> {
|
||||
preferences.lightTheme().set(theme)
|
||||
AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
}
|
||||
|
||||
if (followingSystemTheme && isSelected) {
|
||||
preferences.nightMode().set(nightMode)
|
||||
} else if (!followingSystemTheme) {
|
||||
preferences.nightMode().set(nightMode)
|
||||
}
|
||||
|
||||
(context as? Activity)?.let { activity -> ActivityCompat.recreate(activity) }
|
||||
}
|
||||
}
|
21
app/src/main/java/dev/yokai/presentation/theme/Constants.kt
Normal file
21
app/src/main/java/dev/yokai/presentation/theme/Constants.kt
Normal file
|
@ -0,0 +1,21 @@
|
|||
package dev.yokai.presentation.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
const val SecondaryItemAlpha = .78f
|
||||
const val HalfAlpha = .5f
|
||||
|
||||
object Size {
|
||||
val none = 0.dp
|
||||
val extraExtraTiny = 1.dp
|
||||
val extraTiny = 2.dp
|
||||
val tiny = 4.dp
|
||||
val small = 8.dp
|
||||
val smedium = 12.dp
|
||||
val medium = 16.dp
|
||||
val large = 24.dp
|
||||
val extraLarge = 32.dp
|
||||
val huge = 48.dp
|
||||
val extraHuge = 56.dp
|
||||
val navBarSize = 68.dp
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
package eu.kanade.tachiyomi.core.preference
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
@ -73,3 +77,9 @@ fun Preference<Boolean>.toggle(): Boolean {
|
|||
set(!get())
|
||||
return get()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> Preference<T>.collectAsState(): State<T> {
|
||||
val flow = remember(this) { changes() }
|
||||
return flow.collectAsState(initial = get())
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.backup
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS
|
||||
import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK
|
||||
|
@ -54,6 +55,7 @@ import okio.sink
|
|||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.FileOutputStream
|
||||
|
||||
class BackupCreator(val context: Context) {
|
||||
|
@ -64,6 +66,7 @@ class BackupCreator(val context: Context) {
|
|||
private val sourceManager: SourceManager = Injekt.get()
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
private val customMangaManager: CustomMangaManager = Injekt.get()
|
||||
internal val storageManager: StorageManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Create backup Json file from database
|
||||
|
@ -98,8 +101,7 @@ class BackupCreator(val context: Context) {
|
|||
file = (
|
||||
if (isAutoBackup) {
|
||||
// Get dir of file and create
|
||||
// TODO: Unified Storage
|
||||
val dir = UniFile.fromUri(context, uri)!!.createDirectory("automatic")!!
|
||||
val dir = storageManager.getAutomaticBackupsDirectory()!!
|
||||
|
||||
// Delete older backups
|
||||
val numberOfBackups = preferences.numberOfBackups().get()
|
||||
|
|
|
@ -13,6 +13,8 @@ import androidx.work.Worker
|
|||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import dev.yokai.domain.storage.StoragePreferences
|
||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.system.localeContext
|
||||
|
@ -20,16 +22,16 @@ import eu.kanade.tachiyomi.util.system.notificationManager
|
|||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) :
|
||||
Worker(context, workerParams) {
|
||||
|
||||
override fun doWork(): Result {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val storageManager: StorageManager by injectLazy()
|
||||
val notifier = BackupNotifier(context.localeContext)
|
||||
val uri = inputData.getString(LOCATION_URI_KEY)?.let { Uri.parse(it) }
|
||||
?: preferences.backupsDirectory().get().toUri()
|
||||
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() ?: storageManager.getAutomaticBackupsDirectory()?.uri!!
|
||||
val flags = inputData.getInt(BACKUP_FLAGS_KEY, BackupConst.BACKUP_ALL)
|
||||
val isAutoBackup = inputData.getBoolean(IS_AUTO_BACKUP_KEY, true)
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.net.toUri
|
||||
import coil3.Extras
|
||||
import coil3.ImageLoader
|
||||
import coil3.decode.DataSource
|
||||
|
@ -11,6 +12,7 @@ import coil3.fetch.Fetcher
|
|||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.getOrDefault
|
||||
import coil3.request.Options
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.network.await
|
||||
|
@ -31,6 +33,7 @@ import okio.Path.Companion.toOkioPath
|
|||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
@ -61,6 +64,7 @@ class MangaCoverFetcher(
|
|||
setRatioAndColorsInScope(manga, File(url.substringAfter("file://")))
|
||||
fileLoader(File(url.substringAfter("file://")))
|
||||
}
|
||||
Type.URI -> fileUriLoader(url)
|
||||
null -> error("Invalid image")
|
||||
}
|
||||
}
|
||||
|
@ -304,11 +308,24 @@ class MangaCoverFetcher(
|
|||
)
|
||||
}
|
||||
|
||||
private fun fileUriLoader(uri: String): FetchResult {
|
||||
val source = UniFile.fromUri(options.context, uri.toUri())!!
|
||||
.openInputStream()
|
||||
.source()
|
||||
.buffer()
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(source = source, fileSystem = FileSystem.SYSTEM),
|
||||
mimeType = "image/*",
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getResourceType(cover: String?): Type? {
|
||||
return when {
|
||||
cover.isNullOrEmpty() -> null
|
||||
cover.startsWith("http") || cover.startsWith("Custom-", true) -> Type.URL
|
||||
cover.startsWith("/") || cover.startsWith("file://") -> Type.File
|
||||
cover.startsWith("content") -> Type.URI
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
@ -328,7 +345,7 @@ class MangaCoverFetcher(
|
|||
}
|
||||
|
||||
private enum class Type {
|
||||
File, URL;
|
||||
File, URL, URI;
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -1,18 +1,16 @@
|
|||
package eu.kanade.tachiyomi.data.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import uy.kohesive.injekt.Injekt
|
||||
|
@ -35,7 +33,7 @@ class DownloadCache(
|
|||
private val context: Context,
|
||||
private val provider: DownloadProvider,
|
||||
private val sourceManager: SourceManager,
|
||||
private val preferences: PreferencesHelper = Injekt.get(),
|
||||
private val storageManager: StorageManager = Injekt.get(),
|
||||
) {
|
||||
|
||||
/**
|
||||
|
@ -54,21 +52,11 @@ class DownloadCache(
|
|||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
preferences.downloadsDirectory().changes()
|
||||
.drop(1)
|
||||
.onEach { lastRenew = 0L } // invalidate cache
|
||||
storageManager.changes
|
||||
.onEach { forceRenewCache() } // invalidate cache
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the downloads directory from the user's preferences.
|
||||
*/
|
||||
private fun getDirectoryFromPreference(): UniFile {
|
||||
// TODO: Unified Storage
|
||||
val dir = preferences.downloadsDirectory().get()
|
||||
return UniFile.fromUri(context, dir.toUri())!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the chapter is downloaded.
|
||||
*
|
||||
|
@ -138,7 +126,7 @@ class DownloadCache(
|
|||
private fun renew() {
|
||||
val onlineSources = sourceManager.getOnlineSources()
|
||||
|
||||
val sourceDirs = getDirectoryFromPreference().listFiles().orEmpty()
|
||||
val sourceDirs = storageManager.getDownloadsDirectory()!!.listFiles().orEmpty()
|
||||
.associate { it.name to SourceDirectory(it) }.mapNotNullKeys { entry ->
|
||||
onlineSources.find { provider.getSourceDirName(it).equals(entry.key, ignoreCase = true) }?.id
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
|
|||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
|
@ -31,6 +32,7 @@ class DownloadProvider(private val context: Context) {
|
|||
* Preferences helper.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
private val storageManager: StorageManager by injectLazy()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
|
@ -38,15 +40,11 @@ class DownloadProvider(private val context: Context) {
|
|||
* The root directory for downloads.
|
||||
*/
|
||||
// TODO: Unified Storage
|
||||
private var downloadsDir = preferences.downloadsDirectory().get().let {
|
||||
val dir = UniFile.fromUri(context, it.toUri())
|
||||
DiskUtil.createNoMediaFile(dir, context)
|
||||
dir!!
|
||||
}
|
||||
private var downloadsDir = storageManager.getDownloadsDirectory()
|
||||
|
||||
init {
|
||||
preferences.downloadsDirectory().changes().drop(1).onEach {
|
||||
downloadsDir = UniFile.fromUri(context, it.toUri())!!
|
||||
storageManager.changes.onEach {
|
||||
downloadsDir = storageManager.getDownloadsDirectory()
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
|
@ -58,7 +56,7 @@ class DownloadProvider(private val context: Context) {
|
|||
*/
|
||||
internal fun getMangaDir(manga: Manga, source: Source): UniFile {
|
||||
try {
|
||||
return downloadsDir.createDirectory(getSourceDirName(source))!!
|
||||
return downloadsDir!!.createDirectory(getSourceDirName(source))!!
|
||||
.createDirectory(getMangaDirName(manga))!!
|
||||
} catch (e: NullPointerException) {
|
||||
throw Exception(context.getString(R.string.invalid_download_location))
|
||||
|
@ -71,7 +69,7 @@ class DownloadProvider(private val context: Context) {
|
|||
* @param source the source to query.
|
||||
*/
|
||||
fun findSourceDir(source: Source): UniFile? {
|
||||
return downloadsDir.findFile(getSourceDirName(source), true)
|
||||
return downloadsDir!!.findFile(getSourceDirName(source), true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -4,6 +4,8 @@ import android.app.PendingIntent
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import java.io.File
|
||||
|
@ -30,9 +32,9 @@ object NotificationHandler {
|
|||
* @param context context of application
|
||||
* @param file file containing image
|
||||
*/
|
||||
internal fun openImagePendingActivity(context: Context, file: File): PendingIntent {
|
||||
internal fun openImagePendingActivity(context: Context, file: UniFile): PendingIntent {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
val uri = file.getUriCompat(context)
|
||||
val uri = file.uri.toFile().getUriCompat(context)
|
||||
setDataAndType(uri, "image/*")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
|
|
@ -1,20 +1,16 @@
|
|||
package eu.kanade.tachiyomi.data.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.core.preference.Preference
|
||||
import eu.kanade.tachiyomi.core.preference.PreferenceStore
|
||||
import eu.kanade.tachiyomi.core.preference.getEnum
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob
|
||||
import eu.kanade.tachiyomi.extension.model.InstalledExtensionsOrder
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryItem
|
||||
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet
|
||||
|
@ -23,14 +19,12 @@ import eu.kanade.tachiyomi.ui.reader.settings.PageLayout
|
|||
import eu.kanade.tachiyomi.ui.reader.settings.ReaderBottomButton
|
||||
import eu.kanade.tachiyomi.ui.reader.settings.ReadingModeType
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
|
||||
import eu.kanade.tachiyomi.ui.recents.RecentMangaAdapter
|
||||
import eu.kanade.tachiyomi.ui.recents.RecentsPresenter
|
||||
import eu.kanade.tachiyomi.util.system.Themes
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
@ -66,22 +60,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto
|
|||
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
private val defaultDownloadsDir = Uri.fromFile(
|
||||
File(
|
||||
Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||
context.getString(R.string.app_normalized_name),
|
||||
"downloads",
|
||||
),
|
||||
)
|
||||
|
||||
private val defaultBackupDir = Uri.fromFile(
|
||||
File(
|
||||
Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||
context.getString(R.string.app_normalized_name),
|
||||
"backup",
|
||||
),
|
||||
)
|
||||
|
||||
fun getInt(key: String, default: Int) = preferenceStore.getInt(key, default)
|
||||
fun getStringPref(key: String, default: String = "") = preferenceStore.getString(key, default)
|
||||
fun getStringSet(key: String, default: Set<String>) = preferenceStore.getStringSet(key, default)
|
||||
|
@ -215,8 +193,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto
|
|||
|
||||
fun anilistScoreType() = preferenceStore.getString("anilist_score_type", "POINT_10")
|
||||
|
||||
fun backupsDirectory() = preferenceStore.getString(Keys.backupDirectory, defaultBackupDir.toString())
|
||||
|
||||
fun dateFormat(format: String = preferenceStore.getString(Keys.dateFormat, "").get()): DateFormat = when (format) {
|
||||
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
||||
|
@ -224,8 +200,6 @@ class PreferencesHelper(val context: Context, val preferenceStore: PreferenceSto
|
|||
|
||||
fun appLanguage() = preferenceStore.getString("app_language", "")
|
||||
|
||||
fun downloadsDirectory() = preferenceStore.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||
|
||||
fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true)
|
||||
|
||||
fun folderPerManga() = preferenceStore.getBoolean("create_folder_per_manga", false)
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
package eu.kanade.tachiyomi.source
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toFile
|
||||
import com.github.junrar.Archive
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
@ -10,9 +13,11 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
|||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
|
||||
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
|
||||
import eu.kanade.tachiyomi.util.storage.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.storage.EpubFile
|
||||
import eu.kanade.tachiyomi.util.system.ImageUtil
|
||||
import eu.kanade.tachiyomi.util.system.extension
|
||||
import eu.kanade.tachiyomi.util.system.nameWithoutExtension
|
||||
import eu.kanade.tachiyomi.util.system.writeText
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
@ -20,7 +25,6 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.decodeFromStream
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -35,16 +39,14 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
|
||||
private val langMap = hashMapOf<String, String>()
|
||||
|
||||
fun getMangaLang(manga: SManga, context: Context): String {
|
||||
fun getMangaLang(manga: SManga): String {
|
||||
return langMap.getOrPut(manga.url) {
|
||||
val localDetails = getBaseDirectories(context)
|
||||
.asSequence()
|
||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||
.flatten()
|
||||
val localDetails = getBaseDirectory().findFile(manga.url)?.listFiles().orEmpty()
|
||||
.filter { !it.isDirectory }
|
||||
.firstOrNull { it.extension.equals("json", ignoreCase = true) }
|
||||
|
||||
return if (localDetails != null) {
|
||||
val obj = Json.decodeFromStream<MangaJson>(localDetails.inputStream())
|
||||
val obj = Json.decodeFromStream<MangaJson>(localDetails.openInputStream())
|
||||
obj.lang ?: "other"
|
||||
} else {
|
||||
"other"
|
||||
|
@ -52,49 +54,35 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
}
|
||||
|
||||
fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
|
||||
val dir = getBaseDirectories(context).firstOrNull()
|
||||
if (dir == null) {
|
||||
input.close()
|
||||
return null
|
||||
}
|
||||
var cover = getCoverFile(File("${dir.absolutePath}/${manga.url}"))
|
||||
fun updateCover(manga: SManga, input: InputStream): UniFile {
|
||||
val dir = getBaseDirectory()
|
||||
var cover = getCoverFile(dir.findFile(manga.url))
|
||||
if (cover == null) {
|
||||
cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
|
||||
cover = dir.findFile(manga.url)?.createFile(COVER_NAME)!!
|
||||
}
|
||||
// It might not exist if using the external SD card
|
||||
cover.parentFile?.mkdirs()
|
||||
cover.parentFile?.parentFile?.createDirectory(cover.parentFile?.name)
|
||||
input.use {
|
||||
cover.outputStream().use {
|
||||
cover.openOutputStream().use {
|
||||
input.copyTo(it)
|
||||
}
|
||||
}
|
||||
manga.thumbnail_url = cover.absolutePath
|
||||
manga.thumbnail_url = cover.uri.toString()
|
||||
return cover
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns valid cover file inside [parent] directory.
|
||||
*/
|
||||
private fun getCoverFile(parent: File): File? {
|
||||
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
|
||||
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
|
||||
private fun getCoverFile(parent: UniFile?): UniFile? {
|
||||
return parent?.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
|
||||
it.isFile && ImageUtil.isImage(it.name.orEmpty()) { it.openInputStream() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBaseDirectories(context: Context): List<File> {
|
||||
val library = context.getString(R.string.app_short_name) + File.separator + "local"
|
||||
val normalized = context.getString(R.string.app_normalized_name) + File.separator + "local"
|
||||
val j2k = "TachiyomiJ2K" + File.separator + "local"
|
||||
val tachi = "Tachiyomi" + File.separator + "local"
|
||||
return DiskUtil.getExternalStorages(context).map {
|
||||
listOf(
|
||||
File(it.absolutePath, library),
|
||||
File(it.absolutePath, normalized),
|
||||
File(it.absolutePath, j2k),
|
||||
File(it.absolutePath, tachi),
|
||||
)
|
||||
}.flatten()
|
||||
private fun getBaseDirectory(): UniFile {
|
||||
val storageManager: StorageManager by injectLazy()
|
||||
return storageManager.getLocalSourceDirectory()!!
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -114,49 +102,46 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
query: String,
|
||||
filters: FilterList,
|
||||
): MangasPage {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
|
||||
val time =
|
||||
if (filters === latestFilters) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
|
||||
var mangaDirs = baseDirs
|
||||
.asSequence()
|
||||
.mapNotNull { it.listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory }
|
||||
.filterNot { it.name.startsWith('.') }
|
||||
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
|
||||
|
||||
var mangaDirs = getBaseDirectory().listFiles().orEmpty()
|
||||
.filter { it.isDirectory || !it.name.orEmpty().startsWith('.') }
|
||||
.distinctBy { it.name }
|
||||
.filter {
|
||||
if (time == 0L)
|
||||
it.name.orEmpty().contains(query, ignoreCase = true)
|
||||
else
|
||||
it.lastModified() >= time
|
||||
}
|
||||
|
||||
val state = ((if (filters.isEmpty()) popularFilters else filters)[0] as OrderBy).state
|
||||
when (state?.index) {
|
||||
0 -> {
|
||||
mangaDirs = if (state.ascending) {
|
||||
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
|
||||
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty() })
|
||||
} else {
|
||||
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
|
||||
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name.orEmpty()})
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
mangaDirs = if (state.ascending) {
|
||||
mangaDirs.sortedBy(File::lastModified)
|
||||
mangaDirs.sortedBy(UniFile::lastModified)
|
||||
} else {
|
||||
mangaDirs.sortedByDescending(File::lastModified)
|
||||
mangaDirs.sortedByDescending(UniFile::lastModified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mangas = mangaDirs.map { mangaDir ->
|
||||
SManga.create().apply {
|
||||
title = mangaDir.name
|
||||
url = mangaDir.name
|
||||
title = mangaDir.name.orEmpty()
|
||||
url = mangaDir.name.orEmpty()
|
||||
|
||||
// Try to find the cover
|
||||
for (dir in baseDirs) {
|
||||
val cover = getCoverFile(File("${dir.absolutePath}/$url"))
|
||||
if (cover != null && cover.exists()) {
|
||||
thumbnail_url = cover.absolutePath
|
||||
break
|
||||
}
|
||||
val cover = getCoverFile(mangaDir)
|
||||
if (cover != null && cover.exists()) {
|
||||
thumbnail_url = cover.uri.toString()
|
||||
}
|
||||
|
||||
val manga = this
|
||||
|
@ -166,7 +151,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
val chapter = chapters.last()
|
||||
val format = getFormat(chapter)
|
||||
if (format is Format.Epub) {
|
||||
EpubFile(format.file).use { epub ->
|
||||
EpubFile(format.file.uri.toFile()).use { epub ->
|
||||
epub.fillMangaMetadata(manga)
|
||||
}
|
||||
}
|
||||
|
@ -175,7 +160,7 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
if (thumbnail_url == null) {
|
||||
try {
|
||||
val dest = updateCover(chapter, manga)
|
||||
thumbnail_url = dest?.absolutePath
|
||||
thumbnail_url = dest?.filePath
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
|
@ -191,14 +176,12 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
override suspend fun getLatestUpdates(page: Int) = getSearchManga(page, "", latestFilters)
|
||||
|
||||
override suspend fun getMangaDetails(manga: SManga): SManga {
|
||||
val localDetails = getBaseDirectories(context)
|
||||
.asSequence()
|
||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||
.flatten()
|
||||
val localDetails = getBaseDirectory().findFile(manga.url)?.listFiles().orEmpty()
|
||||
.filter { !it.isDirectory }
|
||||
.firstOrNull { it.extension.equals("json", ignoreCase = true) }
|
||||
|
||||
return if (localDetails != null) {
|
||||
val obj = json.decodeFromStream<MangaJson>(localDetails.inputStream())
|
||||
val obj = json.decodeFromStream<MangaJson>(localDetails.openInputStream())
|
||||
|
||||
obj.lang?.let { langMap[manga.url] = it }
|
||||
SManga.create().apply {
|
||||
|
@ -215,13 +198,13 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
|
||||
fun updateMangaInfo(manga: SManga, lang: String?) {
|
||||
val directory = getBaseDirectories(context).map { File(it, manga.url) }.find {
|
||||
it.exists()
|
||||
} ?: return
|
||||
val directory = getBaseDirectory().findFile(manga.url) ?: return
|
||||
if (!directory.exists()) return
|
||||
|
||||
lang?.let { langMap[manga.url] = it }
|
||||
val json = Json { prettyPrint = true }
|
||||
val existingFileName = directory.listFiles()?.find { it.extension == "json" }?.name
|
||||
val file = File(directory, existingFileName ?: "info.json")
|
||||
val existingFileName = directory.listFiles()?.find { it.extension.equals("json", ignoreCase = true) }?.name
|
||||
val file = directory.createFile(existingFileName ?: "info.json")!!
|
||||
file.writeText(json.encodeToString(manga.toJson(lang)))
|
||||
}
|
||||
|
||||
|
@ -256,24 +239,21 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
|
||||
override suspend fun getChapterList(manga: SManga): List<SChapter> {
|
||||
val chapters = getBaseDirectories(context)
|
||||
.asSequence()
|
||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||
.flatten()
|
||||
.filter { it.isDirectory || isSupportedFile(it.extension) }
|
||||
val chapters = getBaseDirectory().findFile(manga.url)?.listFiles().orEmpty()
|
||||
.filter { it.isDirectory || isSupportedFile(it.extension.orEmpty()) }
|
||||
.map { chapterFile ->
|
||||
SChapter.create().apply {
|
||||
url = "${manga.url}/${chapterFile.name}"
|
||||
name = if (chapterFile.isDirectory) {
|
||||
chapterFile.name
|
||||
chapterFile.name.orEmpty()
|
||||
} else {
|
||||
chapterFile.nameWithoutExtension
|
||||
chapterFile.nameWithoutExtension.orEmpty()
|
||||
}
|
||||
date_upload = chapterFile.lastModified()
|
||||
|
||||
val format = getFormat(chapterFile)
|
||||
if (format is Format.Epub) {
|
||||
EpubFile(format.file).use { epub ->
|
||||
EpubFile(format.file.uri.toFile()).use { epub ->
|
||||
epub.fillChapterMetadata(this)
|
||||
}
|
||||
}
|
||||
|
@ -297,18 +277,19 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
|
||||
fun getFormat(chapter: SChapter): Format {
|
||||
val baseDirs = getBaseDirectories(context)
|
||||
val dir = getBaseDirectory()
|
||||
|
||||
for (dir in baseDirs) {
|
||||
val chapFile = File(dir, chapter.url)
|
||||
if (!chapFile.exists()) continue
|
||||
val (mangaDirName, chapterName) = chapter.url.split('/', limit = 2)
|
||||
val chapFile = dir
|
||||
.findFile(mangaDirName)
|
||||
?.findFile(chapterName)
|
||||
if (chapFile == null || !chapFile.exists())
|
||||
throw Exception(context.getString(R.string.chapter_not_found))
|
||||
|
||||
return getFormat(chapFile)
|
||||
}
|
||||
throw Exception(context.getString(R.string.chapter_not_found))
|
||||
return getFormat(chapFile)
|
||||
}
|
||||
|
||||
private fun getFormat(file: File) = with(file) {
|
||||
private fun getFormat(file: UniFile) = with(file) {
|
||||
when {
|
||||
isDirectory -> Format.Directory(this)
|
||||
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
|
||||
|
@ -318,41 +299,41 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
||||
private fun updateCover(chapter: SChapter, manga: SManga): UniFile? {
|
||||
return try {
|
||||
when (val format = getFormat(chapter)) {
|
||||
is Format.Directory -> {
|
||||
val entry = format.file.listFiles()
|
||||
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
||||
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
|
||||
?.sortedWith { f1, f2 -> f1.name.orEmpty().compareToCaseInsensitiveNaturalOrder(f2.name.orEmpty()) }
|
||||
?.find { !it.isDirectory && ImageUtil.isImage(it.name.orEmpty()) { FileInputStream(it.uri.toFile()) } }
|
||||
|
||||
entry?.let { updateCover(context, manga, it.inputStream()) }
|
||||
entry?.let { updateCover(manga, it.openInputStream()) }
|
||||
}
|
||||
is Format.Zip -> {
|
||||
ZipFile(format.file).use { zip ->
|
||||
ZipFile(format.file.uri.toFile()).use { zip ->
|
||||
val entry = zip.entries().toList()
|
||||
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
|
||||
|
||||
entry?.let { updateCover(context, manga, zip.getInputStream(it)) }
|
||||
entry?.let { updateCover(manga, zip.getInputStream(it)) }
|
||||
}
|
||||
}
|
||||
is Format.Rar -> {
|
||||
Archive(format.file).use { archive ->
|
||||
Archive(format.file.uri.toFile()).use { archive ->
|
||||
val entry = archive.fileHeaders
|
||||
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
|
||||
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
|
||||
|
||||
entry?.let { updateCover(context, manga, archive.getInputStream(it)) }
|
||||
entry?.let { updateCover(manga, archive.getInputStream(it)) }
|
||||
}
|
||||
}
|
||||
is Format.Epub -> {
|
||||
EpubFile(format.file).use { epub ->
|
||||
EpubFile(format.file.uri.toFile()).use { epub ->
|
||||
val entry = epub.getImagesFromPages()
|
||||
.firstOrNull()
|
||||
?.let { epub.getEntry(it) }
|
||||
|
||||
entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
|
||||
entry?.let { updateCover(manga, epub.getInputStream(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -374,10 +355,10 @@ class LocalSource(private val context: Context) : CatalogueSource, UnmeteredSour
|
|||
)
|
||||
|
||||
sealed class Format {
|
||||
data class Directory(val file: File) : Format()
|
||||
data class Zip(val file: File) : Format()
|
||||
data class Rar(val file: File) : Format()
|
||||
data class Epub(val file: File) : Format()
|
||||
data class Directory(val file: UniFile) : Format()
|
||||
data class Zip(val file: UniFile) : Format()
|
||||
data class Rar(val file: UniFile) : Format()
|
||||
data class Epub(val file: UniFile) : Format()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -578,7 +578,7 @@ class LibraryPresenter(
|
|||
|
||||
private fun getLanguage(manga: Manga): String? {
|
||||
return if (manga.isLocal()) {
|
||||
LocalSource.getMangaLang(manga, context)
|
||||
LocalSource.getMangaLang(manga)
|
||||
} else {
|
||||
sourceManager.get(manga.source)?.lang
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@ import com.google.common.primitives.Ints.max
|
|||
import dev.yokai.domain.base.BasePreferences
|
||||
import dev.yokai.domain.ui.settings.ReaderPreferences
|
||||
import dev.yokai.presentation.extension.repo.ExtensionRepoController
|
||||
import dev.yokai.presentation.onboarding.OnboardingController
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.Migrations
|
||||
import eu.kanade.tachiyomi.R
|
||||
|
@ -519,6 +520,9 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
|
|||
// Set start screen
|
||||
if (!handleIntentAction(intent)) {
|
||||
goToStartingTab()
|
||||
if (!basePreferences.hasShownOnboarding().get()) {
|
||||
router.pushController(OnboardingController().withFadeInTransaction())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -124,7 +124,7 @@ class EditMangaDialog : DialogController {
|
|||
},
|
||||
)
|
||||
binding.mangaLang.setSelection(
|
||||
languages.indexOf(LocalSource.getMangaLang(manga, binding.root.context))
|
||||
languages.indexOf(LocalSource.getMangaLang(manga))
|
||||
.takeIf { it > -1 } ?: 0,
|
||||
)
|
||||
} else {
|
||||
|
|
|
@ -32,6 +32,7 @@ import androidx.appcompat.widget.PopupMenu
|
|||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||
import androidx.core.view.isVisible
|
||||
|
@ -118,7 +119,6 @@ import eu.kanade.tachiyomi.util.view.findChild
|
|||
import eu.kanade.tachiyomi.util.view.getText
|
||||
import eu.kanade.tachiyomi.util.view.isControllerVisible
|
||||
import eu.kanade.tachiyomi.util.view.previousController
|
||||
import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe
|
||||
import eu.kanade.tachiyomi.util.view.scrollViewWith
|
||||
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
|
||||
import eu.kanade.tachiyomi.util.view.setStyle
|
||||
|
@ -249,7 +249,6 @@ class MangaDetailsController :
|
|||
if (presenter.preferences.themeMangaDetails()) {
|
||||
setItemColors()
|
||||
}
|
||||
requestFilePermissionsSafe(301, presenter.preferences, presenter.manga.isLocal())
|
||||
}
|
||||
|
||||
private fun setAccentColorValue(colorToUse: Int? = null) {
|
||||
|
@ -1193,7 +1192,7 @@ class MangaDetailsController :
|
|||
fun shareCover() {
|
||||
val cover = presenter.shareCover()
|
||||
if (cover != null) {
|
||||
val stream = cover.getUriCompat(activity!!)
|
||||
val stream = cover.toFile().getUriCompat(activity!!)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, stream)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
|
|
|
@ -3,12 +3,15 @@ package eu.kanade.tachiyomi.ui.manga
|
|||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.net.toFile
|
||||
import coil3.imageLoader
|
||||
import coil3.memory.MemoryCache
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
|
@ -81,6 +84,7 @@ class MangaDetailsPresenter(
|
|||
val db: DatabaseHelper = Injekt.get(),
|
||||
private val downloadManager: DownloadManager = Injekt.get(),
|
||||
chapterFilter: ChapterFilter = Injekt.get(),
|
||||
internal val storageManager: StorageManager = Injekt.get(),
|
||||
) : BaseCoroutinePresenter<MangaDetailsController>(), DownloadQueue.DownloadListener {
|
||||
|
||||
private val customMangaManager: CustomMangaManager by injectLazy()
|
||||
|
@ -719,14 +723,13 @@ class MangaDetailsPresenter(
|
|||
fun shareManga() {
|
||||
val context = Injekt.get<Application>()
|
||||
|
||||
val destDir = File(context.cacheDir, "shared_image")
|
||||
val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!!
|
||||
|
||||
presenterScope.launchIO {
|
||||
destDir.deleteRecursively()
|
||||
try {
|
||||
val file = saveCover(destDir)
|
||||
val uri = saveCover(destDir)
|
||||
withUIContext {
|
||||
view?.shareManga(file)
|
||||
view?.shareManga(uri.uri.toFile())
|
||||
}
|
||||
} catch (_: java.lang.Exception) {
|
||||
}
|
||||
|
@ -831,7 +834,7 @@ class MangaDetailsPresenter(
|
|||
val inputStream =
|
||||
downloadManager.context.contentResolver.openInputStream(uri) ?: return false
|
||||
if (manga.isLocal()) {
|
||||
LocalSource.updateCover(downloadManager.context, manga, inputStream)
|
||||
LocalSource.updateCover(manga, inputStream)
|
||||
view?.setPaletteColor()
|
||||
return true
|
||||
}
|
||||
|
@ -844,11 +847,11 @@ class MangaDetailsPresenter(
|
|||
return false
|
||||
}
|
||||
|
||||
fun shareCover(): File? {
|
||||
fun shareCover(): Uri? {
|
||||
return try {
|
||||
val destDir = File(coverCache.context.cacheDir, "shared_image")
|
||||
val destDir = UniFile.fromFile(coverCache.context.cacheDir)!!.createDirectory("shared_image")!!
|
||||
val file = saveCover(destDir)
|
||||
file
|
||||
file.uri
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
@ -857,39 +860,30 @@ class MangaDetailsPresenter(
|
|||
fun saveCover(): Boolean {
|
||||
return try {
|
||||
val directory = if (preferences.folderPerManga().get()) {
|
||||
val baseDir = Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + Environment.DIRECTORY_PICTURES +
|
||||
File.separator + preferences.context.getString(R.string.app_normalized_name)
|
||||
|
||||
File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title))
|
||||
storageManager.getCoversDirectory()!!.createDirectory(DiskUtil.buildValidFilename(manga.title))!!
|
||||
} else {
|
||||
File(
|
||||
Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + Environment.DIRECTORY_PICTURES +
|
||||
File.separator + preferences.context.getString(R.string.app_normalized_name),
|
||||
)
|
||||
storageManager.getCoversDirectory()!!
|
||||
}
|
||||
val file = saveCover(directory)
|
||||
DiskUtil.scanMedia(preferences.context, file)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveCover(directory: File): File {
|
||||
private fun saveCover(directory: UniFile): UniFile {
|
||||
val cover = coverCache.getCustomCoverFile(manga).takeIf { it.exists() } ?: coverCache.getCoverFile(manga)
|
||||
val type = ImageUtil.findImageType(cover.inputStream())
|
||||
?: throw Exception("Not an image")
|
||||
|
||||
directory.mkdirs()
|
||||
|
||||
// Build destination file.
|
||||
val filename = DiskUtil.buildValidFilename("${manga.title}.${type.extension}")
|
||||
|
||||
val destFile = File(directory, filename)
|
||||
val destFile = directory.createFile(filename)!!
|
||||
cover.inputStream().use { input ->
|
||||
destFile.outputStream().use { output ->
|
||||
destFile.openOutputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -483,7 +483,7 @@ class StatsDetailsPresenter(
|
|||
*/
|
||||
private fun LibraryManga.getLanguage(): String {
|
||||
val code = if (isLocal()) {
|
||||
LocalSource.getMangaLang(this, context)
|
||||
LocalSource.getMangaLang(this)
|
||||
} else {
|
||||
sourceManager.get(source)?.lang
|
||||
} ?: return context.getString(R.string.unknown)
|
||||
|
|
|
@ -42,6 +42,7 @@ import androidx.activity.viewModels
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
|
@ -1670,7 +1671,7 @@ class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
|
|||
* Called from the view model when a page is ready to be shared. It shows Android's default
|
||||
* sharing tool.
|
||||
*/
|
||||
private fun onShareImageResult(file: File, page: ReaderPage, secondPage: ReaderPage? = null) {
|
||||
private fun onShareImageResult(file: UniFile, page: ReaderPage, secondPage: ReaderPage? = null) {
|
||||
val manga = viewModel.manga ?: return
|
||||
val chapter = page.chapter.chapter
|
||||
|
||||
|
@ -1689,7 +1690,7 @@ class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
|
|||
}
|
||||
}, $pageNumber"
|
||||
|
||||
val stream = file.getUriCompat(this)
|
||||
val stream = file.uri.toFile().getUriCompat(this)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
putExtra(Intent.EXTRA_STREAM, stream)
|
||||
|
|
|
@ -5,9 +5,12 @@ import android.graphics.BitmapFactory
|
|||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.net.toFile
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
|
@ -86,6 +89,7 @@ class ReaderViewModel(
|
|||
private val coverCache: CoverCache = Injekt.get(),
|
||||
private val preferences: PreferencesHelper = Injekt.get(),
|
||||
private val chapterFilter: ChapterFilter = Injekt.get(),
|
||||
private val storageManager: StorageManager = Injekt.get(),
|
||||
) : ViewModel() {
|
||||
|
||||
private val mutableState = MutableStateFlow(State())
|
||||
|
@ -285,7 +289,6 @@ class ReaderViewModel(
|
|||
return delegatedSource.pageNumber(url)?.minus(1)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
suspend fun loadChapterURL(url: Uri) {
|
||||
val host = url.host ?: return
|
||||
val context = Injekt.get<Application>()
|
||||
|
@ -743,13 +746,11 @@ class ReaderViewModel(
|
|||
/**
|
||||
* Saves the image of this [page] in the given [directory] and returns the file location.
|
||||
*/
|
||||
private fun saveImage(page: ReaderPage, directory: File, manga: Manga): File {
|
||||
private fun saveImage(page: ReaderPage, directory: UniFile, manga: Manga): UniFile {
|
||||
val stream = page.stream!!
|
||||
val type = ImageUtil.findImageType(stream) ?: throw Exception("Not an image")
|
||||
val context = Injekt.get<Application>()
|
||||
|
||||
directory.mkdirs()
|
||||
|
||||
val chapter = page.chapter.chapter
|
||||
|
||||
// Build destination file.
|
||||
|
@ -757,9 +758,9 @@ class ReaderViewModel(
|
|||
"${manga.title} - ${chapter.preferredChapterName(context, manga, preferences)}".take(225),
|
||||
) + " - ${page.number}.${type.extension}"
|
||||
|
||||
val destFile = File(directory, filename)
|
||||
val destFile = directory.createFile(filename)!!
|
||||
stream().use { input ->
|
||||
destFile.outputStream().use { output ->
|
||||
destFile.openOutputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
@ -769,7 +770,7 @@ class ReaderViewModel(
|
|||
/**
|
||||
* Saves the image of [page1] and [page2] in the given [directory] and returns the file location.
|
||||
*/
|
||||
private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: File, manga: Manga): File {
|
||||
private fun saveImages(page1: ReaderPage, page2: ReaderPage, isLTR: Boolean, @ColorInt bg: Int, directory: UniFile, manga: Manga): UniFile {
|
||||
val stream1 = page1.stream!!
|
||||
ImageUtil.findImageType(stream1) ?: throw Exception("Not an image")
|
||||
val stream2 = page2.stream!!
|
||||
|
@ -781,7 +782,6 @@ class ReaderViewModel(
|
|||
val imageBitmap2 = BitmapFactory.decodeByteArray(imageBytes2, 0, imageBytes2.size)
|
||||
|
||||
val stream = ImageUtil.mergeBitmaps(imageBitmap, imageBitmap2, isLTR, bg)
|
||||
directory.mkdirs()
|
||||
|
||||
val chapter = page1.chapter.chapter
|
||||
val context = Injekt.get<Application>()
|
||||
|
@ -791,9 +791,9 @@ class ReaderViewModel(
|
|||
"${manga.title} - ${chapter.preferredChapterName(context, manga, preferences)}".take(225),
|
||||
) + " - ${page1.number}-${page2.number}.jpg"
|
||||
|
||||
val destFile = File(directory, filename)
|
||||
val destFile = directory.findFile(filename)!!
|
||||
stream.use { input ->
|
||||
destFile.outputStream().use { output ->
|
||||
destFile.openOutputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
@ -814,13 +814,11 @@ class ReaderViewModel(
|
|||
notifier.onClear()
|
||||
|
||||
// Pictures directory.
|
||||
val baseDir = Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + Environment.DIRECTORY_PICTURES +
|
||||
File.separator + context.getString(R.string.app_normalized_name)
|
||||
val baseDir = storageManager.getPagesDirectory()!!
|
||||
val destDir = if (preferences.folderPerManga().get()) {
|
||||
File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title))
|
||||
baseDir.createDirectory(DiskUtil.buildValidFilename(manga.title))!!
|
||||
} else {
|
||||
File(baseDir)
|
||||
baseDir
|
||||
}
|
||||
|
||||
// Copy file in background.
|
||||
|
@ -848,14 +846,12 @@ class ReaderViewModel(
|
|||
notifier.onClear()
|
||||
|
||||
// Pictures directory.
|
||||
val baseDir = Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + Environment.DIRECTORY_PICTURES +
|
||||
File.separator + context.getString(R.string.app_normalized_name)
|
||||
val baseDir = storageManager.getPagesDirectory()!!
|
||||
val destDir = if (preferences.folderPerManga().get()) {
|
||||
File(baseDir + File.separator + DiskUtil.buildValidFilename(manga.title))
|
||||
baseDir.findFile(DiskUtil.buildValidFilename(manga.title))
|
||||
} else {
|
||||
File(baseDir)
|
||||
}
|
||||
baseDir
|
||||
}!!
|
||||
|
||||
try {
|
||||
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
|
||||
|
@ -880,10 +876,9 @@ class ReaderViewModel(
|
|||
val manga = manga ?: return
|
||||
val context = Injekt.get<Application>()
|
||||
|
||||
val destDir = File(context.cacheDir, "shared_image")
|
||||
val destDir = UniFile.fromFile(context.cacheDir)!!.createDirectory("shared_image")!!
|
||||
|
||||
viewModelScope.launchNonCancellable {
|
||||
destDir.deleteRecursively() // Keep only the last shared file
|
||||
val file = saveImage(page, destDir, manga)
|
||||
eventChannel.send(Event.ShareImage(file, page))
|
||||
}
|
||||
|
@ -896,9 +891,8 @@ class ReaderViewModel(
|
|||
val manga = manga ?: return@launch
|
||||
val context = Injekt.get<Application>()
|
||||
|
||||
val destDir = File(context.cacheDir, "shared_image")
|
||||
destDir.deleteRecursively()
|
||||
try {
|
||||
val destDir = UniFile.fromFile(context.cacheDir)!!.findFile("shared_image")!!
|
||||
val file = saveImages(firstPage, secondPage, isLTR, bg, destDir, manga)
|
||||
eventChannel.send(Event.ShareImage(file, firstPage, secondPage))
|
||||
} catch (_: Exception) {
|
||||
|
@ -919,7 +913,7 @@ class ReaderViewModel(
|
|||
if (manga.isLocal()) {
|
||||
val context = Injekt.get<Application>()
|
||||
coverCache.deleteFromCache(manga)
|
||||
LocalSource.updateCover(context, manga, stream())
|
||||
LocalSource.updateCover(manga, stream())
|
||||
R.string.cover_updated
|
||||
SetAsCoverResult.Success
|
||||
} else {
|
||||
|
@ -948,7 +942,7 @@ class ReaderViewModel(
|
|||
* Results of the save image feature.
|
||||
*/
|
||||
sealed class SaveImageResult {
|
||||
class Success(val file: File) : SaveImageResult()
|
||||
class Success(val file: UniFile) : SaveImageResult()
|
||||
class Error(val error: Throwable) : SaveImageResult()
|
||||
}
|
||||
|
||||
|
@ -1010,7 +1004,7 @@ class ReaderViewModel(
|
|||
data class SetCoverResult(val result: SetAsCoverResult) : Event()
|
||||
|
||||
data class SavedImage(val result: SaveImageResult) : Event()
|
||||
data class ShareImage(val file: File, val page: ReaderPage, val extraPage: ReaderPage? = null) : Event()
|
||||
data class ShareImage(val file: UniFile, val page: ReaderPage, val extraPage: ReaderPage? = null) : Event()
|
||||
data class ShareTrackingError(val errors: List<Pair<TrackService, String?>>) : Event()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat
|
|||
import coil3.imageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationHandler
|
||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||
|
@ -37,9 +38,9 @@ class SaveImageNotifier(private val context: Context) {
|
|||
*
|
||||
* @param file image file containing downloaded page image.
|
||||
*/
|
||||
fun onComplete(file: File) {
|
||||
fun onComplete(file: UniFile) {
|
||||
val request = ImageRequest.Builder(context).memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED)
|
||||
.data(file)
|
||||
.data(file.uri)
|
||||
.size(720, 1280)
|
||||
.target(
|
||||
onSuccess = {
|
||||
|
@ -54,7 +55,7 @@ class SaveImageNotifier(private val context: Context) {
|
|||
context.imageLoader.enqueue(request)
|
||||
}
|
||||
|
||||
private fun showCompleteNotification(file: File, image: Bitmap) {
|
||||
private fun showCompleteNotification(file: UniFile, image: Bitmap) {
|
||||
with(notificationBuilder) {
|
||||
setContentTitle(context.getString(R.string.picture_saved))
|
||||
setSmallIcon(R.drawable.ic_photo_24dp)
|
||||
|
@ -70,13 +71,13 @@ class SaveImageNotifier(private val context: Context) {
|
|||
addAction(
|
||||
R.drawable.ic_share_24dp,
|
||||
context.getString(R.string.share),
|
||||
NotificationReceiver.shareImagePendingBroadcast(context, file.absolutePath, notificationId),
|
||||
NotificationReceiver.shareImagePendingBroadcast(context, file.filePath!!, notificationId),
|
||||
)
|
||||
// Delete action
|
||||
addAction(
|
||||
R.drawable.ic_delete_24dp,
|
||||
context.getString(R.string.delete),
|
||||
NotificationReceiver.deleteImagePendingBroadcast(context, file.absolutePath, notificationId),
|
||||
NotificationReceiver.deleteImagePendingBroadcast(context, file.filePath!!, notificationId),
|
||||
)
|
||||
|
||||
updateNotification()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toFile
|
||||
import com.github.junrar.exception.UnsupportedRarV5Exception
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
|
@ -79,14 +80,14 @@ class ChapterLoader(
|
|||
source is HttpSource -> HttpPageLoader(chapter, source)
|
||||
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
|
||||
when (format) {
|
||||
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file)
|
||||
is LocalSource.Format.Zip -> ZipPageLoader(format.file)
|
||||
is LocalSource.Format.Directory -> DirectoryPageLoader(format.file.uri.toFile())
|
||||
is LocalSource.Format.Zip -> ZipPageLoader(format.file.uri.toFile())
|
||||
is LocalSource.Format.Rar -> try {
|
||||
RarPageLoader(format.file)
|
||||
RarPageLoader(format.file.uri.toFile())
|
||||
} catch (e: UnsupportedRarV5Exception) {
|
||||
error(context.getString(R.string.loader_rar5_error))
|
||||
}
|
||||
is LocalSource.Format.Epub -> EpubPageLoader(format.file)
|
||||
is LocalSource.Format.Epub -> EpubPageLoader(format.file.uri.toFile())
|
||||
}
|
||||
}
|
||||
else -> error(context.getString(R.string.source_not_installed))
|
||||
|
|
|
@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.source.Source
|
|||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.util.system.toTempFile
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
||||
|
@ -48,7 +49,7 @@ class DownloadPageLoader(
|
|||
}
|
||||
|
||||
private suspend fun getPagesFromArchive(chapterPath: UniFile): List<ReaderPage> {
|
||||
val loader = ZipPageLoader(File(chapterPath.filePath!!)).also { zipPageLoader = it }
|
||||
val loader = ZipPageLoader(chapterPath.toTempFile(context)).also { zipPageLoader = it }
|
||||
return loader.getPages()
|
||||
}
|
||||
|
||||
|
|
|
@ -80,7 +80,6 @@ import eu.kanade.tachiyomi.util.view.isExpanded
|
|||
import eu.kanade.tachiyomi.util.view.isHidden
|
||||
import eu.kanade.tachiyomi.util.view.moveRecyclerViewUp
|
||||
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
|
||||
import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe
|
||||
import eu.kanade.tachiyomi.util.view.scrollViewWith
|
||||
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
|
||||
import eu.kanade.tachiyomi.util.view.setStyle
|
||||
|
@ -421,7 +420,6 @@ class RecentsController(bundle: Bundle? = null) :
|
|||
binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.expand()
|
||||
}
|
||||
setPadding(binding.downloadBottomSheet.dlBottomSheet.sheetBehavior?.isHideable == true)
|
||||
requestFilePermissionsSafe(301, presenter.preferences)
|
||||
|
||||
binding.downloadBottomSheet.root.sheetBehavior?.isGestureInsetBottomIgnored = true
|
||||
}
|
||||
|
|
|
@ -4,16 +4,17 @@ import android.app.Activity
|
|||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.preference.PreferenceScreen
|
||||
import com.hippo.unifile.UniFile
|
||||
import dev.yokai.domain.storage.StorageManager
|
||||
import dev.yokai.domain.storage.StoragePreferences
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.BackupConst
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||
|
@ -26,23 +27,44 @@ import eu.kanade.tachiyomi.util.system.disableItems
|
|||
import eu.kanade.tachiyomi.util.system.materialAlertDialog
|
||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
|
||||
class SettingsBackupController : SettingsController() {
|
||||
class SettingsDataController : SettingsController() {
|
||||
|
||||
/**
|
||||
* Flags containing information of what to backup.
|
||||
*/
|
||||
private var backupFlags = 0
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
requestFilePermissionsSafe(500, preferences)
|
||||
}
|
||||
internal val storagePreferences: StoragePreferences by injectLazy()
|
||||
internal val storageManager: StorageManager by injectLazy()
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
|
||||
titleRes = R.string.backup_and_restore
|
||||
titleRes = R.string.data_and_storage
|
||||
|
||||
preference {
|
||||
bindTo(storagePreferences.baseStorageDirectory())
|
||||
titleRes = R.string.storage_location
|
||||
|
||||
onClick {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
startActivityForResult(intent, CODE_DATA_DIR)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
activity?.toast(R.string.file_picker_error)
|
||||
}
|
||||
}
|
||||
|
||||
storagePreferences.baseStorageDirectory().changes()
|
||||
.onEach { path ->
|
||||
summary = UniFile.fromUri(context, path.toUri())!!.let { dir ->
|
||||
dir.filePath ?: context.getString(R.string.invalid_location, dir.uri)
|
||||
}
|
||||
}
|
||||
.launchIn(viewScope)
|
||||
}
|
||||
|
||||
preference {
|
||||
key = "pref_create_backup"
|
||||
|
@ -75,7 +97,7 @@ class SettingsBackupController : SettingsController() {
|
|||
(activity as? MainActivity)?.getExtensionUpdates(true)
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "*/*"
|
||||
intent.setDataAndType(storageManager.getBackupsDirectory()!!.uri, "*/*")
|
||||
val title = resources?.getString(R.string.select_backup_file)
|
||||
val chooser = Intent.createChooser(intent, title)
|
||||
startActivityForResult(chooser, CODE_BACKUP_RESTORE)
|
||||
|
@ -107,29 +129,6 @@ class SettingsBackupController : SettingsController() {
|
|||
true
|
||||
}
|
||||
}
|
||||
preference {
|
||||
bindTo(preferences.backupsDirectory())
|
||||
titleRes = R.string.backup_location
|
||||
|
||||
onClick {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
startActivityForResult(intent, CODE_BACKUP_DIR)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
activity?.toast(R.string.file_picker_error)
|
||||
}
|
||||
}
|
||||
|
||||
visibleIf(preferences.backupInterval()) { it > 0 }
|
||||
|
||||
preferences.backupsDirectory().changes()
|
||||
.onEach { path ->
|
||||
val dir = UniFile.fromUri(context, path.toUri())!!
|
||||
val filePath = dir.filePath
|
||||
summary = if (filePath != null) "$filePath/automatic" else "Invalid directory: ${dir.uri}"
|
||||
}
|
||||
.launchIn(viewScope)
|
||||
}
|
||||
intListPreference(activity) {
|
||||
bindTo(preferences.numberOfBackups())
|
||||
titleRes = R.string.max_auto_backups
|
||||
|
@ -165,22 +164,18 @@ class SettingsBackupController : SettingsController() {
|
|||
}
|
||||
|
||||
when (requestCode) {
|
||||
CODE_BACKUP_DIR -> {
|
||||
CODE_DATA_DIR -> {
|
||||
// Get UriPermission so it's possible to write files
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
activity.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
preferences.backupsDirectory().set(uri.toString())
|
||||
val file = UniFile.fromUri(activity, uri)!!
|
||||
storagePreferences.baseStorageDirectory().set(file.uri.toString())
|
||||
}
|
||||
|
||||
CODE_BACKUP_CREATE -> {
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
activity.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
activity.toast(R.string.creating_backup)
|
||||
BackupCreatorJob.startNow(activity, uri, backupFlags)
|
||||
doBackup(backupFlags, uri, true)
|
||||
}
|
||||
|
||||
CODE_BACKUP_RESTORE -> {
|
||||
|
@ -191,8 +186,31 @@ class SettingsBackupController : SettingsController() {
|
|||
}
|
||||
}
|
||||
|
||||
fun createBackup(flags: Int) {
|
||||
private fun doBackup(flags: Int, uri: Uri, requirePersist: Boolean = false) {
|
||||
val activity = activity ?: return
|
||||
|
||||
val actualUri =
|
||||
if (requirePersist) {
|
||||
val intentFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
activity.contentResolver.takePersistableUriPermission(uri, intentFlags)
|
||||
uri
|
||||
} else {
|
||||
UniFile.fromUri(activity, uri)?.createFile(Backup.getBackupFilename())?.uri
|
||||
} ?: return
|
||||
activity.toast(R.string.creating_backup)
|
||||
BackupCreatorJob.startNow(activity, actualUri, flags)
|
||||
}
|
||||
|
||||
fun createBackup(flags: Int, picker: Boolean = false) {
|
||||
backupFlags = flags
|
||||
|
||||
if (!picker) {
|
||||
doBackup(backupFlags, storageManager.getBackupsDirectory()!!.uri)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Android's built-in file creator
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
|
@ -299,7 +317,7 @@ class SettingsBackupController : SettingsController() {
|
|||
}
|
||||
}
|
||||
|
||||
private const val CODE_BACKUP_DIR = 503
|
||||
private const val CODE_DATA_DIR = 104
|
||||
private const val CODE_BACKUP_CREATE = 504
|
||||
private const val CODE_BACKUP_RESTORE = 505
|
||||
|
|
@ -1,24 +1,14 @@
|
|||
package eu.kanade.tachiyomi.ui.setting
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.preference.PreferenceScreen
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.bluelinelabs.conductor.Controller
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.changesIn
|
||||
import eu.kanade.tachiyomi.util.system.withOriginalWidth
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import eu.kanade.tachiyomi.util.view.withFadeTransaction
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys
|
||||
|
||||
class SettingsDownloadController : SettingsController() {
|
||||
|
@ -31,14 +21,9 @@ class SettingsDownloadController : SettingsController() {
|
|||
preference {
|
||||
key = Keys.downloadsDirectory
|
||||
titleRes = R.string.download_location
|
||||
onClick {
|
||||
DownloadDirectoriesDialog(this@SettingsDownloadController).show()
|
||||
}
|
||||
onClick { navigateTo(SettingsDataController()) }
|
||||
|
||||
preferences.downloadsDirectory().changesIn(viewScope) { path ->
|
||||
val dir = UniFile.fromUri(context, path.toUri())!!
|
||||
summary = dir.filePath ?: path
|
||||
}
|
||||
summary = "Moved to Data and Storage!"
|
||||
}
|
||||
switchPreference {
|
||||
key = Keys.downloadOnlyOverWifi
|
||||
|
@ -150,70 +135,9 @@ class SettingsDownloadController : SettingsController() {
|
|||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
when (requestCode) {
|
||||
DOWNLOAD_DIR -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
val context = applicationContext ?: return
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
if (uri != null) {
|
||||
@Suppress("NewApi")
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
}
|
||||
|
||||
val file = UniFile.fromUri(context, uri)!!
|
||||
preferences.downloadsDirectory().set(file.uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun predefinedDirectorySelected(selectedDir: String) {
|
||||
val path = Uri.fromFile(File(selectedDir))
|
||||
preferences.downloadsDirectory().set(path.toString())
|
||||
}
|
||||
|
||||
fun customDirectorySelected() {
|
||||
startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE), DOWNLOAD_DIR)
|
||||
}
|
||||
|
||||
class DownloadDirectoriesDialog(val controller: SettingsDownloadController) :
|
||||
MaterialAlertDialogBuilder(controller.activity!!.withOriginalWidth()) {
|
||||
|
||||
private val preferences: PreferencesHelper = Injekt.get()
|
||||
|
||||
val activity = controller.activity!!
|
||||
|
||||
init {
|
||||
val currentDir = preferences.downloadsDirectory().get()
|
||||
val externalDirs =
|
||||
getExternalDirs() + File(activity.getString(R.string.custom_location))
|
||||
val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir }
|
||||
val items = externalDirs.map { it.path }
|
||||
|
||||
setTitle(R.string.download_location)
|
||||
setSingleChoiceItems(items.toTypedArray(), selectedIndex) { dialog, position ->
|
||||
if (position == externalDirs.lastIndex) {
|
||||
controller.customDirectorySelected()
|
||||
} else {
|
||||
controller.predefinedDirectorySelected(items[position])
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
private fun getExternalDirs(): List<File> {
|
||||
val defaultDir = Environment.getExternalStorageDirectory().absolutePath +
|
||||
File.separator + activity.resources?.getString(R.string.app_normalized_name) +
|
||||
File.separator + "downloads"
|
||||
|
||||
return mutableListOf(File(defaultDir)) +
|
||||
ContextCompat.getExternalFilesDirs(activity, "").filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DOWNLOAD_DIR = 104
|
||||
private fun navigateTo(controller: Controller) {
|
||||
router.pushController(controller.withFadeTransaction())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,10 +73,10 @@ class SettingsMainController : SettingsController(), FloatingSearchInterface {
|
|||
onClick { navigateTo(SettingsTrackingController()) }
|
||||
}
|
||||
preference {
|
||||
iconRes = R.drawable.ic_backup_restore_24dp
|
||||
iconRes = R.drawable.ic_storage_24dp
|
||||
iconTint = tintColor
|
||||
titleRes = R.string.backup_and_restore
|
||||
onClick { navigateTo(SettingsBackupController()) }
|
||||
titleRes = R.string.data_and_storage
|
||||
onClick { navigateTo(SettingsDataController()) }
|
||||
}
|
||||
preference {
|
||||
iconRes = R.drawable.ic_security_24dp
|
||||
|
|
|
@ -9,7 +9,7 @@ import androidx.preference.PreferenceGroup
|
|||
import androidx.preference.PreferenceManager
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsAdvancedController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsAppearanceController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsDataController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBrowseController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsDownloadController
|
||||
|
@ -31,7 +31,7 @@ object SettingsSearchHelper {
|
|||
*/
|
||||
private val settingControllersList: List<KClass<out SettingsController>> = listOf(
|
||||
SettingsAdvancedController::class,
|
||||
SettingsBackupController::class,
|
||||
SettingsDataController::class,
|
||||
SettingsBrowseController::class,
|
||||
SettingsDownloadController::class,
|
||||
SettingsGeneralController::class,
|
||||
|
|
|
@ -60,7 +60,6 @@ import eu.kanade.tachiyomi.util.view.isCollapsed
|
|||
import eu.kanade.tachiyomi.util.view.isCompose
|
||||
import eu.kanade.tachiyomi.util.view.isControllerVisible
|
||||
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
|
||||
import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe
|
||||
import eu.kanade.tachiyomi.util.view.scrollViewWith
|
||||
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
|
||||
import eu.kanade.tachiyomi.util.view.snack
|
||||
|
@ -182,7 +181,6 @@ class BrowseController :
|
|||
updateTitleAndMenu()
|
||||
}
|
||||
|
||||
requestFilePermissionsSafe(301, preferences)
|
||||
binding.bottomSheet.root.onCreate(this)
|
||||
|
||||
basePreferences.extensionInstaller().changes()
|
||||
|
|
|
@ -48,7 +48,6 @@ import eu.kanade.tachiyomi.util.view.applyBottomAnimatedInsets
|
|||
import eu.kanade.tachiyomi.util.view.fullAppBarHeight
|
||||
import eu.kanade.tachiyomi.util.view.inflate
|
||||
import eu.kanade.tachiyomi.util.view.isControllerVisible
|
||||
import eu.kanade.tachiyomi.util.view.requestFilePermissionsSafe
|
||||
import eu.kanade.tachiyomi.util.view.scrollViewWith
|
||||
import eu.kanade.tachiyomi.util.view.setOnQueryTextChangeListener
|
||||
import eu.kanade.tachiyomi.util.view.snack
|
||||
|
@ -182,7 +181,6 @@ open class BrowseSourceController(bundle: Bundle) :
|
|||
} else {
|
||||
binding.progress.isVisible = true
|
||||
}
|
||||
requestFilePermissionsSafe(301, preferences, presenter.source is LocalSource)
|
||||
}
|
||||
|
||||
override fun onDestroyView(view: View) {
|
||||
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.util.storage
|
|||
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.core.content.ContextCompat
|
||||
|
@ -68,11 +69,25 @@ object DiskUtil {
|
|||
val nomedia = dir.findFile(".nomedia")
|
||||
if (nomedia == null) {
|
||||
dir.createFile(".nomedia")
|
||||
context?.let { scanMedia(it, dir.filePath) }
|
||||
context?.let { scanMedia(it, dir) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the given file so that it can be shown in gallery apps, for example.
|
||||
*/
|
||||
fun scanMedia(context: Context, file: UniFile) {
|
||||
scanMedia(context, file.uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the given file so that it can be shown in gallery apps, for example.
|
||||
*/
|
||||
fun scanMedia(context: Context, uri: Uri) {
|
||||
scanMedia(context, uri.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the given file so that it can be shown in gallery apps, for example.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.FileUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
|
||||
val UniFile.nameWithoutExtension: String?
|
||||
get() = name?.substringBeforeLast('.')
|
||||
|
||||
val UniFile.extension: String?
|
||||
get() = name?.replace(nameWithoutExtension.orEmpty(), "")
|
||||
|
||||
fun UniFile.toTempFile(context: Context): File {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)!!
|
||||
val tempFile =
|
||||
File.createTempFile(
|
||||
nameWithoutExtension.orEmpty(),
|
||||
null,
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
FileUtils.copy(inputStream, tempFile.outputStream())
|
||||
} else {
|
||||
BufferedOutputStream(tempFile.outputStream()).use { tmpOut ->
|
||||
inputStream.use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var count: Int
|
||||
while (input.read(buffer).also { count = it } > 0) {
|
||||
tmpOut.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tempFile
|
||||
}
|
||||
|
||||
fun UniFile.writeText(string: String) {
|
||||
this.openOutputStream().use {
|
||||
it.write(string.toByteArray())
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
package eu.kanade.tachiyomi.util.view
|
||||
|
||||
import android.Manifest
|
||||
import android.animation.Animator
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.ActivityManager
|
||||
|
@ -8,12 +7,9 @@ import android.content.ClipData
|
|||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -26,7 +22,6 @@ import androidx.annotation.CallSuper
|
|||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.math.MathUtils
|
||||
|
@ -53,7 +48,6 @@ import com.bluelinelabs.conductor.Router
|
|||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.databinding.MainActivityBinding
|
||||
import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface
|
||||
|
@ -73,7 +67,6 @@ import eu.kanade.tachiyomi.util.system.ImageUtil
|
|||
import eu.kanade.tachiyomi.util.system.dpToPx
|
||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||
import eu.kanade.tachiyomi.util.system.ignoredSystemInsets
|
||||
import eu.kanade.tachiyomi.util.system.materialAlertDialog
|
||||
import eu.kanade.tachiyomi.util.system.rootWindowInsetsCompat
|
||||
import eu.kanade.tachiyomi.util.system.toInt
|
||||
import eu.kanade.tachiyomi.util.system.toast
|
||||
|
@ -780,54 +773,6 @@ fun Controller.setAppBarBG(value: Float, includeTabView: Boolean = false) {
|
|||
}
|
||||
}
|
||||
|
||||
fun Controller.requestFilePermissionsSafe(
|
||||
requestCode: Int,
|
||||
preferences: PreferencesHelper,
|
||||
showA11PermissionAnyway: Boolean = false,
|
||||
) {
|
||||
val activity = activity ?: return
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
val permissions = mutableListOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
permissions.forEach { permission ->
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
activity,
|
||||
permission,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
requestPermissions(arrayOf(permission), requestCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||
!Environment.isExternalStorageManager() &&
|
||||
(!preferences.hasDeniedA11FilePermission().get() || showA11PermissionAnyway)
|
||||
) {
|
||||
preferences.hasDeniedA11FilePermission().set(true)
|
||||
activity.materialAlertDialog()
|
||||
.setTitle(R.string.all_files_permission_required)
|
||||
.setMessage(R.string.external_storage_permission_notice)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||
"package:${activity.packageName}".toUri(),
|
||||
)
|
||||
try {
|
||||
activity.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
val intent2 = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
activity.startActivity(intent2)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
} else if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.R || Environment.isExternalStorageManager()) && !preferences.backupInterval().isSet()) {
|
||||
preferences.backupInterval().set(24)
|
||||
BackupCreatorJob.setupTask(activity, 24)
|
||||
}
|
||||
}
|
||||
|
||||
fun Controller.withFadeTransaction(): RouterTransaction {
|
||||
return RouterTransaction.with(this)
|
||||
.pushChangeHandler(fadeTransactionHandler())
|
||||
|
|
5
app/src/main/res/drawable/ic_storage_24dp.xml
Normal file
5
app/src/main/res/drawable/ic_storage_24dp.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M2,20h20v-4L2,16v4zM4,17h2v2L4,19v-2zM2,4v4h20L22,4L2,4zM6,7L4,7L4,5h2v2zM2,14h20v-4L2,10v4zM4,11h2v2L4,13v-2z"/>
|
||||
</vector>
|
|
@ -8,6 +8,26 @@
|
|||
<string name="external_storage_permission_notice">TachiyomiJ2K requires access to all files in Android 11 to download chapters, create automatic backups, and read local series. \n\nOn the next screen, enable \"Allow access to manage all files.\"</string>
|
||||
<string name="external_storage_download_notice">TachiyomiJ2K requires access to all files to download chapters. Tap here, then enable \"Allow access to manage all files.\"</string>
|
||||
|
||||
<string name="onboarding_heading">Welcome!</string>
|
||||
<string name="onboarding_description">Let\'s pick some defaults. You can always change these things later in the settings.</string>
|
||||
<string name="onboarding_finish">Get started</string>
|
||||
<string name="onboarding_storage_info">Select a folder where %1$s will store chapter downloads, backups, and more.\n\nA dedicated folder is recommended.\n\nSelected folder: %2$s</string>
|
||||
<string name="onboarding_storage_action_select">Select a folder</string>
|
||||
<string name="onboarding_storage_help_info">Updating from an older version and not sure what to select? Refer to the Tachiyomi upgrade section on the Mihon storage guide for more information.</string>
|
||||
<string name="onboarding_storage_help_action">Storage guide</string>
|
||||
<string name="onboarding_permission_type_required">Required</string>
|
||||
<string name="onboarding_permission_type_optional">Optional but recommended</string>
|
||||
<string name="onboarding_permission_install_apps">Install apps permission</string>
|
||||
<string name="onboarding_permission_install_apps_description">To install the app on updates.</string>
|
||||
<string name="onboarding_permission_notifications">Notification permission</string>
|
||||
<string name="onboarding_permission_notifications_description">Get notified for library updates and more.</string>
|
||||
<string name="onboarding_permission_ignore_battery_opts">Background battery usage</string>
|
||||
<string name="onboarding_permission_ignore_battery_opts_description">Avoid interruptions to long-running library updates, downloads, and backup restores.</string>
|
||||
<string name="onboarding_permission_action_grant">Grant</string>
|
||||
|
||||
<string name="no_location_set">No storage location set</string>
|
||||
<string name="invalid_location">Invalid location: %s</string>
|
||||
|
||||
<!--Models-->
|
||||
|
||||
<!-- Manga Type -->
|
||||
|
@ -773,6 +793,10 @@
|
|||
<string name="series_opens_new_chapters">Series shortcuts opens new chapters</string>
|
||||
<string name="no_new_chapters_open_details">When there\'s no new chapters, the series\' details will open instead</string>
|
||||
|
||||
<!-- Storage -->
|
||||
<string name="data_and_storage">Data and storage</string>
|
||||
<string name="storage_location">Storage location</string>
|
||||
|
||||
<!-- Backup -->
|
||||
<string name="backup">Backup</string>
|
||||
<string name="backup_and_restore">Backup and restore</string>
|
||||
|
|
|
@ -22,6 +22,5 @@ android.enableJetifier=true
|
|||
android.useAndroidX=true
|
||||
org.gradle.jvmargs=-Xmx2048M
|
||||
org.gradle.caching=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
android.nonFinalResIds=false
|
||||
|
|
|
@ -8,8 +8,12 @@ animation = { module = "androidx.compose.animation:animation", version.ref = "co
|
|||
foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
|
||||
material = { module = "androidx.compose.material:material", version.ref = "compose" }
|
||||
material3 = { module = "androidx.compose.material3:material3", version = "1.2.1" }
|
||||
material-motion = { module = "io.github.fornewid:material-motion-compose-core", version = "1.0.7" }
|
||||
lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
|
||||
ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
|
||||
ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
|
||||
icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }
|
||||
|
||||
[bundles]
|
||||
compose = [ "animation", "foundation", "material", "material3", "material-motion", "ui", "ui-tooling-preview", "icons" ]
|
||||
|
|
|
@ -29,7 +29,7 @@ flexbox = { module = "com.google.android.flexbox:flexbox", version = "3.0.0" }
|
|||
flexible-adapter-ui = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui", version.ref = "flexible-adapter" }
|
||||
flexible-adapter = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter", version.ref = "flexible-adapter" }
|
||||
google-services = { module = "com.google.gms:google-services", version = "4.4.1" }
|
||||
gradle = { module = "com.android.tools.build:gradle", version = "8.1.4" }
|
||||
gradle = { module = "com.android.tools.build:gradle", version = "8.2.0" }
|
||||
guava = { module = "com.google.guava:guava", version = "31.1-android" }
|
||||
image-decoder = { module = "com.github.tachiyomiorg:image-decoder", version = "e08e9be535" }
|
||||
injekt-core = { module = "com.github.inorichi.injekt:injekt-core", version = "65b0440" }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue