chore: Some more effort moving widget to its own module

This commit is contained in:
Ahmad Ansori Palembani 2024-06-17 13:21:27 +07:00
parent 79b5494307
commit 4a9a7813e0
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
34 changed files with 205 additions and 64 deletions

View file

@ -0,0 +1,17 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "yokai.presentation.core"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
api(libs.material)
}

View file

21
presentation/core/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View file

@ -0,0 +1,12 @@
package yokai.presentation.core
object Constants {
const val MAIN_ACTIVITY = "eu.kanade.tachiyomi.ui.main.MainActivity"
const val SEARCH_ACTIVITY = "eu.kanade.tachiyomi.ui.main.SearchActivity"
const val SHORTCUT_RECENTS = "eu.kanade.tachiyomi.SHOW_RECENTS"
const val SHORTCUT_MANGA = "eu.kanade.tachiyomi.SHOW_MANGA"
const val SHORTCUT_MANGA_BACK = "eu.kanade.tachiyomi.SHOW_MANGA_BACK"
const val MANGA_EXTRA = "manga"
}

View file

@ -0,0 +1,14 @@
package yokai.presentation.core.util
import android.content.Context
import android.content.Intent
import yokai.presentation.core.Constants
object IntentCommon {
fun openManga(context: Context, id: Long?, canReturnToMain: Boolean = false) =
Intent(context, Class.forName(Constants.SEARCH_ACTIVITY))
.apply {
action = if (canReturnToMain) Constants.SHORTCUT_MANGA_BACK else Constants.SHORTCUT_MANGA
putExtra(Constants.MANGA_EXTRA, id)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="cover_placeholder">#1F888888</color>
</resources>

View file

@ -0,0 +1,33 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "yokai.presentation.widget"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = compose.versions.compose.compiler.get()
}
}
dependencies {
implementation(projects.core)
implementation(projects.data)
implementation(projects.domain)
implementation(projects.i18n)
implementation(projects.presentation.core)
implementation(androidx.glance.appwidget)
implementation(libs.coil3)
}

View file

21
presentation/widget/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name="eu.kanade.tachiyomi.appwidget.UpdatesGridGlanceReceiver"
android:enabled="@bool/glance_appwidget_available"
android:exported="false"
android:label="@string/updates">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/updates_grid_glance_widget_info" />
</receiver>
</application>
</manifest>

View file

@ -0,0 +1,14 @@
package yokai.presentation.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
class TachiyomiWidgetManager {
suspend fun Context.init() {
val manager = GlanceAppWidgetManager(this)
if (manager.getGlanceIds(UpdatesGridGlanceWidget::class.java).isNotEmpty()) {
UpdatesGridGlanceWidget().loadData()
}
}
}

View file

@ -0,0 +1,8 @@
package yokai.presentation.widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
class UpdatesGridGlanceReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = UpdatesGridGlanceWidget().apply { loadData() }
}

View file

@ -0,0 +1,129 @@
package yokai.presentation.widget
import android.app.Application
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import androidx.core.graphics.drawable.toBitmap
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.ImageProvider
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background
import androidx.glance.layout.fillMaxSize
import coil3.executeBlocking
import coil3.imageLoader
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.size.Precision
import coil3.size.Scale
import coil3.transform.RoundedCornersTransformation
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.recents.RecentsPresenter
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.MainScope
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import yokai.presentation.widget.components.CoverHeight
import yokai.presentation.widget.components.CoverWidth
import yokai.presentation.widget.components.LockedWidget
import yokai.presentation.widget.components.UpdatesWidget
import yokai.presentation.widget.util.appWidgetBackgroundRadius
import yokai.presentation.widget.util.calculateRowAndColumnCount
import java.util.*
import kotlin.math.min
// FIXME
class UpdatesGridGlanceWidget(
private val app: Application = Injekt.get(),
//private val preferences: PreferencesHelper by injectLazy(),
) : GlanceAppWidget() {
private val coroutineScope = MainScope()
private var data: List<Pair<Long, Bitmap?>>? = null
override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
// If app lock enabled, don't do anything
if (preferences.useBiometrics().get()) {
LockedWidget()
} else {
UpdatesWidget(data)
}
}
}
fun loadData(list: List<Pair<Manga, Long>>? = null) {
coroutineScope.launchIO {
// Don't show anything when lock is active
if (preferences.useBiometrics().get()) {
updateAll(app)
return@launchIO
}
val manager = GlanceAppWidgetManager(app)
val ids = manager.getGlanceIds(this@UpdatesGridGlanceWidget::class.java)
if (ids.isEmpty()) return@launchIO
val (rowCount, columnCount) = ids
.flatMap { manager.getAppWidgetSizes(it) }
.maxBy { it.height.value * it.width.value }
.calculateRowAndColumnCount()
val processList = list ?: RecentsPresenter.getRecentManga(customAmount = min(50, rowCount * columnCount))
data = prepareList(processList, rowCount * columnCount)
ids.forEach { update(app, it) }
}
}
private fun prepareList(processList: List<Pair<Manga, Long>>, take: Int): List<Pair<Long, Bitmap?>> {
// Resize to cover size
val widthPx = CoverWidth.value.toInt().dpToPx
val heightPx = CoverHeight.value.toInt().dpToPx
val roundPx = app.resources.getDimension(R.dimen.appwidget_inner_radius)
return processList
// .distinctBy { it.first.id }
.sortedByDescending { it.second }
.take(take)
.map { it.first }
.map { updatesView ->
val request = ImageRequest.Builder(app)
.data(updatesView)
.memoryCachePolicy(CachePolicy.DISABLED)
.precision(Precision.EXACT)
.size(widthPx, heightPx)
.scale(Scale.FILL)
.let {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
it.transformations(RoundedCornersTransformation(roundPx))
} else {
it // Handled by system
}
}
.build()
Pair(updatesView.id!!, app.imageLoader.executeBlocking(request).image?.asDrawable(app.resources)?.toBitmap())
}
}
companion object {
val DateLimit: Calendar
get() = Calendar.getInstance().apply {
time = Date()
add(Calendar.MONTH, -3)
}
}
}
val ContainerModifier = GlanceModifier
.fillMaxSize()
.background(ImageProvider(R.drawable.appwidget_background))
.appWidgetBackground()
.appWidgetBackgroundRadius()

View file

@ -0,0 +1,48 @@
package yokai.presentation.widget.components
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.padding
import androidx.glance.text.Text
import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import yokai.i18n.MR
import yokai.presentation.core.Constants
import yokai.presentation.widget.ContainerModifier
import yokai.presentation.widget.R
import yokai.presentation.widget.util.stringResource
@Composable
fun LockedWidget() {
val context = LocalContext.current
val clazz = Class.forName(Constants.MAIN_ACTIVITY)
val intent = Intent(context, clazz).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
Box(
modifier = GlanceModifier
.clickable(actionStartActivity(intent))
.then(ContainerModifier)
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(MR.strings.appwidget_unavailable_locked),
style = TextStyle(
color = ColorProvider(Color(context.getColor(R.color.appwidget_on_secondary_container))),
fontSize = 12.sp,
textAlign = TextAlign.Center,
),
)
}
}

View file

@ -0,0 +1,48 @@
package yokai.presentation.widget.components
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.layout.Box
import androidx.glance.layout.ContentScale
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.size
import yokai.presentation.widget.R
import yokai.presentation.widget.util.appWidgetInnerRadius
val CoverWidth = 58.dp
val CoverHeight = 87.dp
@Composable
fun UpdatesMangaCover(
modifier: GlanceModifier = GlanceModifier,
cover: Bitmap?,
) {
Box(
modifier = modifier
.size(width = CoverWidth, height = CoverHeight)
.appWidgetInnerRadius(),
) {
if (cover != null) {
Image(
provider = ImageProvider(cover),
contentDescription = null,
modifier = GlanceModifier
.fillMaxSize()
.appWidgetInnerRadius(),
contentScale = ContentScale.Crop,
)
} else {
// Enjoy placeholder
Image(
provider = ImageProvider(R.drawable.appwidget_cover_error),
contentDescription = null,
modifier = GlanceModifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
}

View file

@ -0,0 +1,76 @@
package yokai.presentation.widget.components
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.CircularProgressIndicator
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.padding
import androidx.glance.text.Text
import yokai.i18n.MR
import yokai.presentation.core.Constants
import yokai.presentation.core.util.IntentCommon
import yokai.presentation.widget.ContainerModifier
import yokai.presentation.widget.util.calculateRowAndColumnCount
import yokai.presentation.widget.util.stringResource
@Composable
fun UpdatesWidget(data: List<Pair<Long, Bitmap?>>?) {
val (rowCount, columnCount) = LocalSize.current.calculateRowAndColumnCount()
val clazz = Class.forName(Constants.MAIN_ACTIVITY)
val mainIntent = Intent(LocalContext.current, clazz).setAction(Constants.SHORTCUT_RECENTS)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
Column(
modifier = ContainerModifier.clickable(actionStartActivity(mainIntent)),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (data == null) {
CircularProgressIndicator()
} else if (data.isEmpty()) {
Text(text = stringResource(MR.strings.no_recent_read_updated_manga))
} else {
(0 until rowCount).forEach { i ->
val coverRow = (0 until columnCount).mapNotNull { j ->
data.getOrNull(j + (i * columnCount))
}
if (coverRow.isNotEmpty()) {
Row(
modifier = GlanceModifier
.padding(vertical = 4.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
coverRow.forEach { (mangaId, cover) ->
Box(
modifier = GlanceModifier
.padding(horizontal = 3.dp),
contentAlignment = Alignment.Center,
) {
val intent = IntentCommon.openManga(LocalContext.current, mangaId, true)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
// https://issuetracker.google.com/issues/238793260
.addCategory(mangaId.toString())
UpdatesMangaCover(
modifier = GlanceModifier.clickable(actionStartActivity(intent)),
cover = cover,
)
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,44 @@
package yokai.presentation.widget.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.DpSize
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.appwidget.cornerRadius
import dev.icerock.moko.resources.StringResource
import yokai.presentation.widget.R
import yokai.util.lang.getMString
fun GlanceModifier.appWidgetBackgroundRadius(): GlanceModifier {
return this.cornerRadius(R.dimen.appwidget_background_radius)
}
fun GlanceModifier.appWidgetInnerRadius(): GlanceModifier {
return this.cornerRadius(R.dimen.appwidget_inner_radius)
}
@Composable
fun stringResource(id: StringResource): String {
return LocalContext.current.getMString(id)
}
/**
* Calculates row-column count.
*
* Row
* Numerator: Container height - container vertical padding
* Denominator: Cover height + cover vertical padding
*
* Column
* Numerator: Container width - container horizontal padding
* Denominator: Cover width + cover horizontal padding
*
* @return pair of row and column count
*/
fun DpSize.calculateRowAndColumnCount(): Pair<Int, Int> {
// Hack: Size provided by Glance manager is not reliable so take at least 1 row and 1 column
// Set max to 10 children each direction because of Glance limitation
val rowCount = (height.value / 95).toInt().coerceIn(1, 10)
val columnCount = (width.value / 64).toInt().coerceIn(1, 10)
return Pair(rowCount, columnCount)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/appwidget_secondary_container" />
<corners android:radius="@dimen/appwidget_background_radius" />
</shape>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/cover_placeholder" />
<corners android:radius="@dimen/appwidget_inner_radius" />
</shape>
</item>
<item
android:top="24dp"
android:bottom="24dp"
android:left="24dp"
android:right="24dp">
<vector
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/cover_placeholder"
android:pathData="M21,5v6.59l-2.29,-2.3c-0.39,-0.39 -1.03,-0.39 -1.42,0L14,12.59 10.71,9.3c-0.39,-0.39 -1.02,-0.39 -1.41,0L6,12.59 3,9.58L3,5c0,-1.1 0.9,-2 2,-2h14c1.1,0 2,0.9 2,2zM18,11.42l3,3.01L21,19c0,1.1 -0.9,2 -2,2L5,21c-1.1,0 -2,-0.9 -2,-2v-6.58l2.29,2.29c0.39,0.39 1.02,0.39 1.41,0l3.3,-3.3 3.29,3.29c0.39,0.39 1.02,0.39 1.41,0l3.3,-3.28z"/>
</vector>
</item>
</layer-list>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="appwidget_background">@color/m3_sys_color_dynamic_light_surface</color>
<color name="appwidget_on_background">@color/m3_sys_color_dynamic_light_on_surface</color>
<color name="appwidget_surface_variant">@color/m3_sys_color_dynamic_light_surface_variant</color>
<color name="appwidget_on_surface_variant">@color/m3_sys_color_dynamic_light_on_surface_variant</color>
<color name="appwidget_secondary_container">@color/m3_sys_color_dynamic_light_secondary_container</color>
<color name="appwidget_on_secondary_container">@color/m3_sys_color_dynamic_light_on_secondary_container</color>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="appwidget_background_radius">16dp</dimen>
<dimen name="appwidget_inner_radius">12dp</dimen>
</resources>