feat: add basic series widget (#1094)

* chore: add the required glance dependencies

* feat: add new module for the widget

* feat: allow adding widget to home screen

* feat: display all active series on widget

* feat: update series from the widget

* feat: update the look of the widget

* feat: update the widget defaults

Update so that we display a slightly better UI when first starting the widget.

* feat: display better progress string

* feat: set max title length to 3 lines

* refactor: move the provideGlance method up

* feat: show progress indicator while performing update

* feat: update the text style for title slightly

* refactor: update to use string resources

* feat: sort the series based on user preference

* feat: notify glance widget of data updates

When the data updates such as updating a series progress, or adding a new series, we need to notify
the glance widget. This is done via a singleton class that listens to the repository, and on updates
to the data fires the required worker to update the widget.

* style: ktlint fixes

* style: detekt fixes
This commit is contained in:
Troy Rijkaard
2023-10-08 17:03:57 +01:00
committed by GitHub
parent 19833953b4
commit bfb8f9af3f
23 changed files with 496 additions and 2 deletions

View File

@@ -67,6 +67,7 @@ dependencies {
implementation(project(":features:login"))
implementation(project(":features:search"))
implementation(project(":features:series"))
implementation(project(":features:serieswidget"))
implementation(project(":features:settings"))
implementation(project(":libraries:core"))
implementation(project(":libraries:database"))
@@ -89,6 +90,7 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
implementation(libs.androidx.fragment)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.hilt.work)
implementation(libs.androidx.lifecycle.extensions)
implementation(libs.androidx.lifecycle.livedata)

View File

@@ -7,9 +7,13 @@ import androidx.work.Configuration
import com.chesire.lifecyklelog.LifecykleLog
import com.chesire.lifecyklelog.LogHandler
import com.chesire.nekome.core.preferences.ApplicationPreferences
import com.chesire.nekome.services.DataRefreshNotifier
import com.chesire.nekome.services.WorkerQueue
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
/**
@@ -27,6 +31,10 @@ class App : Application(), Configuration.Provider {
@Inject
lateinit var workerQueue: WorkerQueue
@Inject
lateinit var dataRefreshNotifier: DataRefreshNotifier
@OptIn(DelicateCoroutinesApi::class)
override fun onCreate() {
super.onCreate()
@@ -46,6 +54,9 @@ class App : Application(), Configuration.Provider {
workerQueue.enqueueAuthRefresh()
workerQueue.enqueueSeriesRefresh()
workerQueue.enqueueUserRefresh()
GlobalScope.launch {
dataRefreshNotifier.initialize()
}
}
override fun getWorkManagerConfiguration() =

View File

@@ -0,0 +1,35 @@
package com.chesire.nekome.services
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.chesire.nekome.datasource.series.SeriesRepository
import javax.inject.Inject
import javax.inject.Singleton
private const val WIDGET_DATA_NOTIFY_TAG = "WidgetData"
private const val WIDGET_DATA_UNIQUE_NAME = "WidgetSync"
@Singleton
class DataRefreshNotifier @Inject constructor(
private val workManager: WorkManager,
private val seriesRepository: SeriesRepository
) {
/**
* Initialize the notifier and listen to any data updates.
*/
suspend fun initialize() {
seriesRepository.getSeries().collect {
val request = OneTimeWorkRequestBuilder<WidgetDataWorker>()
.addTag(WIDGET_DATA_NOTIFY_TAG)
.build()
workManager.enqueueUniqueWork(
WIDGET_DATA_UNIQUE_NAME,
ExistingWorkPolicy.REPLACE,
request
)
}
}
}

View File

@@ -0,0 +1,18 @@
package com.chesire.nekome.services
import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.chesire.nekome.feature.serieswidget.ui.SeriesWidget
class WidgetDataWorker(
private val context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
SeriesWidget().updateAll(context)
return Result.success()
}
}

View File

@@ -4,7 +4,7 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
internal val DarkColorPalette = darkColorScheme(
val DarkColorPalette = darkColorScheme(
primary = Color(0xFF65d3ff),
onPrimary = Color(0xFF003546),
primaryContainer = Color(0xFF004d64),
@@ -30,7 +30,7 @@ internal val DarkColorPalette = darkColorScheme(
onSurfaceVariant = Color(0xFFc0c8cd)
)
internal val LightColorPalette = lightColorScheme(
val LightColorPalette = lightColorScheme(
primary = Color(0xFF006783),
onPrimary = Color(0xFFffffff),
primaryContainer = Color(0xFFbde9ff),

View File

@@ -81,6 +81,9 @@
<string name="series_detail_failure">シリーズ%sの更新に失敗しました。再試行してください</string>
<string name="series_detail_delete">デリート</string>
<string name="series_widget_description">アニメ一覧</string>
<string name="series_widget_increment">+1</string>
<string name="rating_none">評価なし</string>
<string name="login_username">Kitsu eメール</string>

View File

@@ -81,6 +81,9 @@
<string name="series_detail_failure">Failed to update series %s, please try again</string>
<string name="series_detail_delete">Delete</string>
<string name="series_widget_description">Anime list</string>
<string name="series_widget_increment">+1</string>
<string name="rating_none">No rating</string>
<string name="login_username">Kitsu email</string>

1
features/serieswidget/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.google.dagger.hilt.android)
alias(libs.plugins.google.devtools.ksp)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.chesire.nekome.feature.serieswidget"
compileSdk = libs.versions.sdk.get().toInt()
defaultConfig {
minSdk = 21
consumerProguardFiles("consumer-rules.pro")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
}
dependencies {
implementation(project(":core:compose"))
implementation(project(":core:preferences"))
implementation(project(":core:resources"))
implementation(project(":libraries:core"))
implementation(project(":libraries:datasource:series"))
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.material3)
implementation(libs.bundles.compose)
implementation(libs.google.hilt.android)
implementation(libs.kotlin.result)
implementation(libs.timber)
debugImplementation(libs.androidx.compose.ui.tooling)
ksp(libs.google.hilt.android.compiler)
}

View File

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<receiver
android:name="GlanceDataReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/series_widget_info" />
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,10 @@
package com.chesire.nekome.feature.serieswidget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import com.chesire.nekome.feature.serieswidget.ui.SeriesWidget
class GlanceDataReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget
get() = SeriesWidget()
}

View File

@@ -0,0 +1,13 @@
package com.chesire.nekome.feature.serieswidget
import com.chesire.nekome.feature.serieswidget.ui.SeriesWidgetViewModel
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@EntryPoint
@InstallIn(SingletonComponent::class)
interface SeriesWidgetEntryPoint {
fun seriesWidgetViewModel(): SeriesWidgetViewModel
}

View File

@@ -0,0 +1,39 @@
package com.chesire.nekome.feature.serieswidget.core
import com.chesire.nekome.core.flags.SeriesType
import com.chesire.nekome.core.flags.UserSeriesStatus
import com.chesire.nekome.core.preferences.SeriesPreferences
import com.chesire.nekome.core.preferences.flags.SortOption
import com.chesire.nekome.datasource.series.SeriesDomain
import com.chesire.nekome.datasource.series.SeriesRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class RetrieveSeriesUseCase @Inject constructor(
private val seriesRepository: SeriesRepository,
private val pref: SeriesPreferences
) {
suspend operator fun invoke(): Flow<List<SeriesDomain>> {
val sortOption = pref.sort.first()
return seriesRepository
.getSeries()
.map { seriesList ->
seriesList
.filter { series -> series.type == SeriesType.Anime }
.filter { series -> series.userSeriesStatus == UserSeriesStatus.Current }
.filter { series -> series.totalLength == 0 || series.progress < series.totalLength }
.sortedWith(
when (sortOption) {
SortOption.Default -> compareBy { it.userId }
SortOption.Title -> compareBy { it.title }
SortOption.StartDate -> compareBy { it.startDate }
SortOption.EndDate -> compareBy { it.endDate }
SortOption.Rating -> compareBy { it.rating }
}
)
}
}
}

View File

@@ -0,0 +1,30 @@
package com.chesire.nekome.feature.serieswidget.core
import com.chesire.nekome.datasource.series.SeriesRepository
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.mapEither
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class UpdateSeriesUseCase @Inject constructor(
private val seriesRepository: SeriesRepository
) {
suspend operator fun invoke(seriesId: Int): Result<Unit, Unit> {
val currentSeries = seriesRepository.getSeries(seriesId)
return withContext(Dispatchers.IO) {
seriesRepository
.updateSeries(
currentSeries.userId,
currentSeries.progress + 1,
currentSeries.userSeriesStatus,
currentSeries.rating
)
.mapEither(
success = { Unit },
failure = { Unit }
)
}
}
}

View File

@@ -0,0 +1,21 @@
package com.chesire.nekome.feature.serieswidget.ui
import com.chesire.nekome.datasource.series.SeriesDomain
import javax.inject.Inject
class DomainMapper @Inject constructor() {
fun toSeries(domain: SeriesDomain): Series {
return Series(
userId = domain.userId,
title = domain.title,
progress = buildProgress(domain.progress, domain.totalLength),
isUpdating = false
)
}
private fun buildProgress(progress: Int, totalLength: Int): String {
val maxLengthString = if (totalLength == 0) "-" else totalLength
return "$progress / $maxLengthString"
}
}

View File

@@ -0,0 +1,153 @@
package com.chesire.nekome.feature.serieswidget.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.unit.dp
import androidx.glance.Button
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.appwidget.CircularProgressIndicator
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.appWidgetBackground
import androidx.glance.appwidget.background
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent
import androidx.glance.color.ColorProvider
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.chesire.nekome.core.compose.theme.DarkColorPalette
import com.chesire.nekome.core.compose.theme.LightColorPalette
import com.chesire.nekome.feature.serieswidget.SeriesWidgetEntryPoint
import com.chesire.nekome.resources.StringResource
import dagger.hilt.EntryPoints
class SeriesWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val viewModel = EntryPoints.get(
context,
SeriesWidgetEntryPoint::class.java
).seriesWidgetViewModel()
provideContent {
val state by viewModel.uiState.collectAsState()
Render(
state = state,
incrementText = context.getString(StringResource.series_widget_increment),
updateSeries = { viewModel.execute(ViewAction.UpdateSeries(it)) }
)
}
}
@Composable
private fun Render(
state: UIState,
incrementText: String,
updateSeries: (Int) -> Unit
) {
LazyColumn(
modifier = GlanceModifier
.appWidgetBackground()
.background(
day = LightColorPalette.surface,
night = DarkColorPalette.surface
)
.fillMaxSize()
.padding(8.dp)
) {
items(
items = state.series,
itemId = { it.userId.toLong() }
) { item ->
Column {
SeriesCard(
id = item.userId,
title = item.title,
progress = item.progress,
isUpdating = item.isUpdating,
incrementText = incrementText,
updateSeries = updateSeries
)
Spacer(modifier = GlanceModifier.height(8.dp).fillMaxWidth())
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun SeriesCard(
id: Int,
title: String,
progress: String,
isUpdating: Boolean,
incrementText: String,
updateSeries: (Int) -> Unit
) {
Row(
modifier = GlanceModifier.fillMaxWidth()
.background(
day = LightColorPalette.primaryContainer,
night = DarkColorPalette.primaryContainer
)
.cornerRadius(8.dp)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = GlanceModifier
.defaultWeight()
.padding(end = 8.dp)
) {
Text(
text = title,
style = TextStyle(
color = ColorProvider(
day = LightColorPalette.onPrimaryContainer,
night = DarkColorPalette.onPrimaryContainer
),
fontWeight = FontWeight.Medium
),
maxLines = 3
)
Text(
text = progress,
style = TextStyle(
color = ColorProvider(
day = LightColorPalette.onPrimaryContainer,
night = DarkColorPalette.onPrimaryContainer
)
)
)
}
if (isUpdating) {
CircularProgressIndicator(
color = ColorProvider(
day = LightColorPalette.onPrimaryContainer,
night = DarkColorPalette.onPrimaryContainer
),
modifier = GlanceModifier.height(36.dp)
)
} else {
Button(
text = incrementText,
onClick = { updateSeries(id) }
)
}
}
}
}

View File

@@ -0,0 +1,68 @@
package com.chesire.nekome.feature.serieswidget.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.chesire.nekome.feature.serieswidget.core.RetrieveSeriesUseCase
import com.chesire.nekome.feature.serieswidget.core.UpdateSeriesUseCase
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SeriesWidgetViewModel @Inject constructor(
private val retrieveSeries: RetrieveSeriesUseCase,
private val updateSeries: UpdateSeriesUseCase,
private val mapper: DomainMapper
) : ViewModel() {
private val _uiState = MutableStateFlow(UIState())
val uiState = _uiState.asStateFlow()
init {
viewModelScope.launch {
retrieveSeries().collect { series ->
_uiState.update {
it.copy(series = series.map(mapper::toSeries))
}
}
}
}
fun execute(viewAction: ViewAction) {
when (viewAction) {
is ViewAction.UpdateSeries -> handleUpdateSeries(viewAction.id)
}
}
private fun handleUpdateSeries(id: Int) {
_uiState.update {
it.copy(series = updateIsUpdating(id, true))
}
viewModelScope.launch {
updateSeries(id)
.onSuccess {
_uiState.update {
it.copy(series = updateIsUpdating(id, false))
}
}
.onFailure {
_uiState.update {
it.copy(series = updateIsUpdating(id, false))
}
}
}
}
private fun updateIsUpdating(id: Int, isUpdating: Boolean): List<Series> {
return _uiState.value.series.map {
if (it.userId == id) {
it.copy(isUpdating = isUpdating)
} else {
it
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.chesire.nekome.feature.serieswidget.ui
data class UIState(
val series: List<Series> = emptyList()
)
data class Series(
val userId: Int,
val title: String,
val progress: String,
val isUpdating: Boolean
)

View File

@@ -0,0 +1,6 @@
package com.chesire.nekome.feature.serieswidget.ui
sealed interface ViewAction {
data class UpdateSeries(val id: Int) : ViewAction
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="80dp"
android:minHeight="80dp"
android:updatePeriodMillis="86400000"
android:description="@string/series_widget_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen" />

View File

@@ -3,6 +3,7 @@ aboutlibraries = "10.9.1"
accompanist = "0.32.0"
android-gradle-plugin = "8.1.2"
androidx-espresso = "3.5.1"
androidx-glance = "1.0.0"
androidx-hilt = "1.0.0"
androidx-navigation = "2.7.4"
androidx-room = "2.5.2"
@@ -38,6 +39,8 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo
androidx-core = { module = "androidx.core:core-ktx", version = "1.12.0" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version = "1.0.0" }
androidx-fragment = { module = "androidx.fragment:fragment-ktx", version = "1.6.1" }
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "androidx-glance" }
androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "androidx-glance" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidx-hilt" }

View File

@@ -23,6 +23,7 @@ include(
":features:login",
":features:search",
":features:series",
":features:serieswidget",
":features:settings",
":libraries:core",
":libraries:database",