Add favorites part to clock widget

This commit is contained in:
MM20 2022-03-31 23:02:59 +02:00
parent 93bd62545d
commit 5075c368a2
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
18 changed files with 298 additions and 132 deletions

View File

@ -18,8 +18,8 @@ interface SearchDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insertSkipExisting(items: FavoritesItemEntity)
@Query("SELECT * FROM Searchable WHERE pinned > 0 AND (NOT :excludeCalendarEvents OR NOT `key` LIKE 'calendar://%') ORDER BY pinned DESC, launchCount DESC")
fun getFavorites(excludeCalendarEvents: Boolean = false): Flow<List<FavoritesItemEntity>>
@Query("SELECT * FROM Searchable WHERE pinned > 0 AND (NOT :excludeCalendarEvents OR NOT `key` LIKE 'calendar://%') ORDER BY pinned DESC, launchCount DESC LIMIT :limit")
fun getFavorites(excludeCalendarEvents: Boolean = false, limit: Int): Flow<List<FavoritesItemEntity>>
@Query("SELECT * FROM Searchable WHERE pinned > 0 AND `key` LIKE 'calendar://%' ORDER BY pinned DESC, launchCount DESC")
fun getPinnedCalendarEvents(): Flow<List<FavoritesItemEntity>>

View File

@ -17,8 +17,8 @@ data class FavoritesItem(
var launchCount: Int,
var pinPosition: Int,
var hidden: Boolean
) : KoinComponent {
private val serializer: SearchableSerializer by inject { parametersOf(searchable) }
) {
private val serializer: SearchableSerializer = getSerializer(searchable)
fun toDatabaseEntity(): FavoritesItemEntity? {
val serializer = serializer

View File

@ -1,21 +1,41 @@
package de.mm20.launcher2.favorites
import android.content.Context
import de.mm20.launcher2.appshortcuts.AppShortcutDeserializer
import de.mm20.launcher2.appshortcuts.AppShortcutSerializer
import de.mm20.launcher2.calendar.CalendarEventDeserializer
import de.mm20.launcher2.calendar.CalendarEventSerializer
import de.mm20.launcher2.contacts.ContactDeserializer
import de.mm20.launcher2.contacts.ContactSerializer
import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.database.entities.FavoritesItemEntity
import de.mm20.launcher2.files.*
import de.mm20.launcher2.ktx.ceilToInt
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.NullDeserializer
import de.mm20.launcher2.search.NullSerializer
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.data.CalendarEvent
import de.mm20.launcher2.search.data.Searchable
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.websites.WebsiteDeserializer
import de.mm20.launcher2.websites.WebsiteSerializer
import de.mm20.launcher2.wikipedia.WikipediaDeserializer
import de.mm20.launcher2.wikipedia.WikipediaSerializer
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
interface FavoritesRepository {
fun getFavorites(excludeCalendarEvents: Boolean = false): Flow<List<Searchable>>
fun getFavorites(
columns: Int,
maxRows: Int? = null,
excludeCalendarEvents: Boolean = false
): Flow<List<Searchable>>
fun getPinnedCalendarEvents(): Flow<List<Searchable>>
fun isPinned(searchable: Searchable): Flow<Boolean>
fun pinItem(searchable: Searchable)
@ -32,42 +52,42 @@ interface FavoritesRepository {
internal class FavoritesRepositoryImpl(
private val context: Context,
private val database: AppDatabase,
private val dataStore: LauncherDataStore
) : FavoritesRepository, KoinComponent {
private val scope = CoroutineScope(Job() + Dispatchers.Default)
override fun getFavorites(excludeCalendarEvents: Boolean): Flow<List<Searchable>> =
override fun getFavorites(
columns: Int,
maxRows: Int?,
excludeCalendarEvents: Boolean
): Flow<List<Searchable>> =
channelFlow {
withContext(Dispatchers.IO) {
val gridColumns = dataStore.data.map { it.grid.columnCount }.distinctUntilChanged()
val dao = database.searchDao()
val pinnedFavorites = dao.getFavorites(excludeCalendarEvents).map {
it.mapNotNull {
val item = fromDatabaseEntity(it).searchable
if (item == null) {
dao.deleteByKey(it.key)
}
return@mapNotNull item
}
}
pinnedFavorites.collectLatest { pinned ->
gridColumns.collectLatest { columns ->
var favCount = (pinned.size.toDouble() / columns).ceilToInt() * columns
if (pinned.size < columns) favCount += columns
val autoFavs = dao.getAutoFavorites(favCount - pinned.size).mapNotNull {
val pinnedFavorites =
dao.getFavorites(excludeCalendarEvents, columns * (maxRows ?: 20)).map {
it.mapNotNull {
val item = fromDatabaseEntity(it).searchable
if (item == null) {
dao.deleteByKey(it.key)
}
return@mapNotNull item
}
send(pinned + autoFavs)
}
pinnedFavorites.collectLatest { pinned ->
var favCount = (pinned.size.toDouble() / columns).ceilToInt() * columns
if (pinned.size < columns) favCount += columns
val autoFavs = dao.getAutoFavorites(
favCount.coerceAtMost((maxRows ?: 20) * columns) - pinned.size
).mapNotNull {
val item = fromDatabaseEntity(it).searchable
if (item == null) {
dao.deleteByKey(it.key)
}
return@mapNotNull item
}
send(pinned + autoFavs)
}
}
}
@ -173,7 +193,7 @@ internal class FavoritesRepositoryImpl(
private fun fromDatabaseEntity(entity: FavoritesItemEntity): FavoritesItem {
val deserializer: SearchableDeserializer = get { parametersOf(entity.serializedSearchable) }
val deserializer: SearchableDeserializer = getDeserializer(context, entity.serializedSearchable)
return FavoritesItem(
key = entity.key,
searchable = deserializer.deserialize(entity.serializedSearchable.substringAfter("#")),

View File

@ -18,80 +18,5 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val favoritesModule = module {
factory { (searchable: Searchable) ->
if (searchable is LauncherApp) {
return@factory LauncherAppSerializer()
}
if (searchable is AppShortcut) {
return@factory AppShortcutSerializer()
}
if (searchable is CalendarEvent) {
return@factory CalendarEventSerializer()
}
if (searchable is Contact) {
return@factory ContactSerializer()
}
if (searchable is Wikipedia) {
return@factory WikipediaSerializer()
}
if (searchable is GDriveFile) {
return@factory GDriveFileSerializer()
}
if (searchable is OneDriveFile) {
return@factory OneDriveFileSerializer()
}
if (searchable is OwncloudFile) {
return@factory OwncloudFileSerializer()
}
if (searchable is NextcloudFile) {
return@factory NextcloudFileSerializer()
}
if (searchable is LocalFile) {
return@factory LocalFileSerializer()
}
if (searchable is Website) {
return@factory WebsiteSerializer()
}
return@factory NullSerializer()
}
factory { (serialized: String) ->
val type = serialized.substringBefore("#")
if (type == "app") {
return@factory LauncherAppDeserializer(androidContext())
}
if (type == "shortcut") {
return@factory AppShortcutDeserializer(androidContext())
}
if (type == "calendar") {
return@factory CalendarEventDeserializer(androidContext())
}
if (type == "contact") {
return@factory ContactDeserializer(androidContext())
}
if (type == "wikipedia") {
return@factory WikipediaDeserializer(androidContext())
}
if (type == "gdrive") {
return@factory GDriveFileDeserializer()
}
if (type == "onedrive") {
return@factory OneDriveFileDeserializer()
}
if (type == "nextcloud") {
return@factory NextcloudFileDeserializer()
}
if (type == "owncloud") {
return@factory OwncloudFileDeserializer()
}
if (type == "file") {
return@factory LocalFileDeserializer(androidContext())
}
if (type == "website") {
return@factory WebsiteDeserializer()
}
return@factory NullDeserializer()
}
single<FavoritesRepository> { FavoritesRepositoryImpl(androidContext(), get(), get()) }
single<FavoritesRepository> { FavoritesRepositoryImpl(androidContext(), get()) }
}

View File

@ -0,0 +1,95 @@
package de.mm20.launcher2.favorites
import android.content.Context
import de.mm20.launcher2.appshortcuts.AppShortcutDeserializer
import de.mm20.launcher2.appshortcuts.AppShortcutSerializer
import de.mm20.launcher2.calendar.CalendarEventDeserializer
import de.mm20.launcher2.calendar.CalendarEventSerializer
import de.mm20.launcher2.contacts.ContactDeserializer
import de.mm20.launcher2.contacts.ContactSerializer
import de.mm20.launcher2.files.*
import de.mm20.launcher2.search.NullDeserializer
import de.mm20.launcher2.search.NullSerializer
import de.mm20.launcher2.search.SearchableDeserializer
import de.mm20.launcher2.search.SearchableSerializer
import de.mm20.launcher2.search.data.*
import de.mm20.launcher2.websites.WebsiteDeserializer
import de.mm20.launcher2.websites.WebsiteSerializer
import de.mm20.launcher2.wikipedia.WikipediaDeserializer
import de.mm20.launcher2.wikipedia.WikipediaSerializer
internal fun getSerializer(searchable: Searchable?): SearchableSerializer {
if (searchable is LauncherApp) {
return LauncherAppSerializer()
}
if (searchable is AppShortcut) {
return AppShortcutSerializer()
}
if (searchable is CalendarEvent) {
return CalendarEventSerializer()
}
if (searchable is Contact) {
return ContactSerializer()
}
if (searchable is Wikipedia) {
return WikipediaSerializer()
}
if (searchable is GDriveFile) {
return GDriveFileSerializer()
}
if (searchable is OneDriveFile) {
return OneDriveFileSerializer()
}
if (searchable is OwncloudFile) {
return OwncloudFileSerializer()
}
if (searchable is NextcloudFile) {
return NextcloudFileSerializer()
}
if (searchable is LocalFile) {
return LocalFileSerializer()
}
if (searchable is Website) {
return WebsiteSerializer()
}
return NullSerializer()
}
internal fun getDeserializer(context: Context, serialized: String): SearchableDeserializer {
val type = serialized.substringBefore("#")
if (type == "app") {
return LauncherAppDeserializer(context)
}
if (type == "shortcut") {
return AppShortcutDeserializer(context)
}
if (type == "calendar") {
return CalendarEventDeserializer(context)
}
if (type == "contact") {
return ContactDeserializer(context)
}
if (type == "wikipedia") {
return WikipediaDeserializer(context)
}
if (type == "gdrive") {
return GDriveFileDeserializer()
}
if (type == "onedrive") {
return OneDriveFileDeserializer()
}
if (type == "nextcloud") {
return NextcloudFileDeserializer()
}
if (type == "owncloud") {
return OwncloudFileDeserializer()
}
if (type == "file") {
return LocalFileDeserializer(context)
}
if (type == "website") {
return WebsiteDeserializer()
}
return NullDeserializer()
}

View File

@ -485,6 +485,8 @@
<string name="preference_clock_widget_style_summary">Select a clock</string>
<string name="preference_clockwidget_date_part">Date</string>
<string name="preference_clockwidget_date_part_summary">Show the current date</string>
<string name="preference_clockwidget_favorites_part">Favorites</string>
<string name="preference_clockwidget_favorites_part_summary">Show the first row of pinned items</string>
<string name="preference_clockwidget_music_part">Media</string>
<string name="preference_clockwidget_music_part_summary">Show media controls when there is an active media session</string>
<string name="preference_clockwidget_battery_part">Battery</string>

View File

@ -39,6 +39,7 @@ fun createFactorySettings(context: Context): Settings {
.setBatteryPart(true)
.setDatePart(true)
.setMusicPart(true)
.setFavoritesPart(false)
.build()
)
.setFavorites(

View File

@ -56,6 +56,7 @@ message Settings {
bool music_part = 4;
bool battery_part = 5;
bool alarm_part = 6;
bool favorites_part = 7;
}
ClockWidgetSettings clock_widget = 7;

View File

@ -35,8 +35,9 @@ fun ProvideSettings(
val favoritesEnabled by remember {
combine(
widgetRepository.isFavoritesWidgetEnabled(),
dataStore.data.map { it.favorites.enabled }
) { a, b -> a || b }.distinctUntilChanged()
dataStore.data.map { it.favorites.enabled },
dataStore.data.map { it.clockWidget.favoritesPart },
) { a, b, c -> a || b || c }.distinctUntilChanged()
}.collectAsState(true)
val gridColumns by remember {

View File

@ -3,7 +3,6 @@ package de.mm20.launcher2.ui.launcher.search
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.appshortcuts.AppShortcutRepository
@ -75,8 +74,13 @@ class SearchVM : ViewModel(), KoinComponent {
return@collectLatest
}
widgetRepository.isCalendarWidgetEnabled().collectLatest { excludeCalendar ->
favoritesRepository.getFavorites(excludeCalendarEvents = excludeCalendar).collectLatest {
favorites.value = it
dataStore.data.map { it.grid.columnCount }.collectLatest { columns ->
favoritesRepository.getFavorites(
columns = columns,
excludeCalendarEvents = excludeCalendar
).collectLatest {
favorites.value = it
}
}
}
}

View File

@ -1,7 +1,6 @@
package de.mm20.launcher2.ui.launcher.search.common
import androidx.activity.compose.BackHandler
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@ -42,7 +41,7 @@ import kotlinx.coroutines.delay
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GridItem(modifier: Modifier = Modifier, item: Searchable) {
fun GridItem(modifier: Modifier = Modifier, item: Searchable, showLabels: Boolean = true) {
val viewModel = remember(item.key) { GridItemVM(item) }
val context = LocalContext.current
@ -74,16 +73,18 @@ fun GridItem(modifier: Modifier = Modifier, item: Searchable) {
showPopup = true
}
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
text = item.label,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (showLabels) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
text = item.label,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (showPopup) {
ItemPopup(origin = bounds, searchable = item, onDismissRequest = { showPopup = false })
}

View File

@ -14,9 +14,9 @@ import kotlin.math.ceil
fun SearchResultGrid(
items: List<Searchable>,
modifier: Modifier = Modifier,
showLabels: Boolean = true,
columns: Int = LocalGridColumns.current,
) {
val columns = LocalGridColumns.current
Column(
modifier = modifier
.animateContentSize()
@ -31,7 +31,9 @@ fun SearchResultGrid(
GridItem(
modifier = Modifier
.weight(1f)
.padding(4.dp, 8.dp), item = item
.padding(4.dp, 8.dp),
item = item,
showLabels = showLabels
)
} else {
Spacer(modifier = Modifier.weight(1f))

View File

@ -56,7 +56,6 @@ fun ClockWidget(
if (layout == ClockWidgetLayout.Vertical) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.height(IntrinsicSize.Min),
) {
Box(
modifier = Modifier.clickable(

View File

@ -32,6 +32,7 @@ class ClockWidgetVM : ViewModel(), KoinComponent {
if (it.musicPart) providers += MusicPartProvider()
if (it.batteryPart) providers += BatteryPartProvider()
if (it.alarmPart) providers += AlarmPartProvider()
if (it.favoritesPart) providers += FavoritesPartProvider()
partProviders.value = providers
}
}
@ -48,9 +49,11 @@ class ClockWidgetVM : ViewModel(), KoinComponent {
val rankings = providers.map { it.getRanking(context) }
combine(rankings) { r ->
var prov = providers[0]
var ranking = r[0]
for (i in 1 until providers.size) {
if (r[i - 1] < r[i]) {
if (ranking < r[i]) {
prov = providers[i]
ranking = r[i]
}
}
return@combine prov

View File

@ -0,0 +1,69 @@
package de.mm20.launcher2.ui.launcher.widgets.clock.parts
import android.content.Context
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.favorites.FavoritesRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.preferences.Settings.ClockWidgetSettings.ClockWidgetLayout
import de.mm20.launcher2.ui.launcher.search.common.grid.SearchResultGrid
import de.mm20.launcher2.widgets.WidgetRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class FavoritesPartProvider : PartProvider, KoinComponent {
private val favoritesRepository: FavoritesRepository by inject()
private val widgetRepository: WidgetRepository by inject()
private val dataStore: LauncherDataStore by inject()
override fun getRanking(context: Context): Flow<Int> = flow {
emit(2)
}
@Composable
override fun Component(layout: ClockWidgetLayout) {
val columns by remember(layout) {
dataStore.data.map {
val c = it.grid.columnCount
if (layout == ClockWidgetLayout.Horizontal) c - 2 else c
}
}.collectAsState(0)
val excludeCalendar by remember { widgetRepository.isCalendarWidgetEnabled() }.collectAsState(
true
)
val favorites by remember(columns, excludeCalendar, layout) {
favoritesRepository.getFavorites(
columns = columns,
maxRows = 1,
excludeCalendarEvents = excludeCalendar
)
}.collectAsState(emptyList())
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.wrapContentHeight()
) {
SearchResultGrid(
items = favorites, showLabels = false, columns = columns,
)
}
}
}

View File

@ -16,13 +16,19 @@ import org.koin.core.component.inject
class FavoritesWidgetVM: ViewModel(), KoinComponent {
private val favoritesRepository: FavoritesRepository by inject()
private val widgetRepository: WidgetRepository by inject()
private val dataStore: LauncherDataStore by inject()
val favorites = MutableLiveData<List<Searchable>>(emptyList())
init {
viewModelScope.launch {
widgetRepository.isCalendarWidgetEnabled().collectLatest { excludeCalendar ->
favoritesRepository.getFavorites(excludeCalendarEvents = excludeCalendar).collectLatest {
favorites.value = it
dataStore.data.map { it.grid.columnCount }.collectLatest { columns ->
favoritesRepository.getFavorites(
columns = columns,
excludeCalendarEvents = excludeCalendar
).collectLatest {
favorites.value = it
}
}
}
}

View File

@ -66,6 +66,20 @@ fun ClockWidgetSettingsScreen() {
viewModel.setDatePart(it)
}
)
val favoritesPart by viewModel.favoritesPart.observeAsState()
SwitchPreference(
title = stringResource(R.string.preference_clockwidget_favorites_part),
summary = stringResource(R.string.preference_clockwidget_favorites_part_summary),
icon = Icons.Rounded.Star,
value = favoritesPart == true,
onValueChanged = {
viewModel.setFavoritesPart(it)
}
)
}
}
item {
PreferenceCategory {
val musicPart by viewModel.musicPart.observeAsState()
SwitchPreference(
title = stringResource(R.string.preference_clockwidget_music_part),

View File

@ -46,6 +46,29 @@ class ClockWidgetSettingsScreenVM : ViewModel(), KoinComponent {
.setClockWidget(
it.clockWidget.toBuilder()
.setDatePart(datePart)
.also {
if (datePart) {
it.setFavoritesPart(false)
}
}
).build()
}
}
}
val favoritesPart = dataStore.data.map { it.clockWidget.favoritesPart }.asLiveData()
fun setFavoritesPart(favoritesPart: Boolean) {
viewModelScope.launch {
dataStore.updateData {
it.toBuilder()
.setClockWidget(
it.clockWidget.toBuilder()
.setFavoritesPart(favoritesPart)
.also {
if (favoritesPart) {
it.setDatePart(false)
}
}
).build()
}
}