[WIP] Add experimental feed support

This commit is contained in:
MM20
2026-01-04 00:58:35 +01:00
parent 720ea787a9
commit 664419b3c4
35 changed files with 876 additions and 15 deletions

View File

@@ -10,6 +10,7 @@ import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
@@ -27,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.IntOffset
@@ -48,6 +50,7 @@ import de.mm20.launcher2.ui.ktx.animateTo
import de.mm20.launcher2.ui.launcher.scaffold.ClockAndWidgetsHomeComponent
import de.mm20.launcher2.ui.launcher.scaffold.ClockHomeComponent
import de.mm20.launcher2.ui.launcher.scaffold.DismissComponent
import de.mm20.launcher2.ui.launcher.scaffold.FeedComponent
import de.mm20.launcher2.ui.launcher.scaffold.Gesture
import de.mm20.launcher2.ui.launcher.scaffold.LaunchComponent
import de.mm20.launcher2.ui.launcher.scaffold.LauncherScaffold
@@ -168,7 +171,7 @@ abstract class SharedLauncherActivity(
val darkSearchBar = LocalPreferDarkContentOverWallpaper.current
&& searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark
LaunchedEffect(dimBackground && darkTheme) {
/*LaunchedEffect(dimBackground && darkTheme) {
if (dimBackground && darkTheme) {
val windowAttributes = window.attributes
windowAttributes.flags =
@@ -182,7 +185,7 @@ abstract class SharedLauncherActivity(
window.attributes = windowAttributes
window.setDimAmount(0f)
}
}
}*/
val enterTransitionProgress = remember { mutableStateOf(100f) }
var enterTransition by remember {
@@ -211,6 +214,10 @@ abstract class SharedLauncherActivity(
OverlayHost(
modifier = Modifier
.background(
if (dimBackground && darkTheme) Color( 0f, 0f, 0f, 0.3f)
else Color.Transparent
)
.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
@@ -266,6 +273,7 @@ abstract class SharedLauncherActivity(
openKeyboard = searchBarAutofocus,
)
val widgetComponent by lazy { WidgetsComponent }
val feedComponent by lazy { FeedComponent(this@SharedLauncherActivity) }
fun getScaffoldGesture(
action: GestureAction?,
@@ -318,6 +326,11 @@ abstract class SharedLauncherActivity(
animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push,
)
is GestureAction.Feed -> ScaffoldGesture(
component = FeedComponent(this@SharedLauncherActivity),
animation = if (gesture.orientation == null) ScaffoldAnimation.ZoomIn else ScaffoldAnimation.Push,
)
is GestureAction.Launch if (searchable != null) -> ScaffoldGesture(
component = LaunchComponent(
this@SharedLauncherActivity,

View File

@@ -0,0 +1,145 @@
package de.mm20.launcher2.ui.launcher.scaffold
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.alpha
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import de.mm20.launcher2.feed.FeedConnection
import de.mm20.launcher2.feed.FeedService
import de.mm20.launcher2.preferences.feed.FeedSettings
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toIntOffset
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
internal class FeedComponent(
private val context: Context,
) : ScaffoldComponent(), KoinComponent {
private val feedSettings: FeedSettings by inject()
private val feedService: FeedService by inject()
override val permanent: Boolean = true
@Composable
override fun Component(
modifier: Modifier,
insets: PaddingValues,
state: LauncherScaffoldState
) {
val activity = LocalActivity.current
val lifecycleOwner = LocalLifecycleOwner.current
val progress = state.currentProgress
val feedProgress = remember { mutableFloatStateOf(0f) }
val feedProviderPackage by remember { feedSettings.providerPackage }.collectAsState(null)
var feedConnection by remember { mutableStateOf<FeedConnection?>(null) }
val feedReady = feedConnection?.ready?.collectAsState(false)
val feedAvailable = feedConnection?.available?.collectAsState(null)
DisposableEffect(feedProviderPackage) {
val conn = feedProviderPackage?.let {
feedService.createFeedInstance(activity as AppCompatActivity, it) { p ->
feedProgress.floatValue = p
}
}
feedConnection = conn
onDispose {
conn?.destroy()
}
}
val enableScroll = state.currentComponent == this && progress > 0f && progress < 1f
LaunchedEffect(enableScroll) {
if (enableScroll) {
feedConnection?.startScroll()
} else {
feedConnection?.endScroll()
}
}
LaunchedEffect(progress) {
feedConnection?.onScroll(progress)
}
LaunchedEffect(feedProgress.floatValue) {
if (isActive) {
state.setProgress(feedProgress.floatValue)
if (feedProgress.floatValue <= 0f) {
state.onPredictiveBackEnd()
}
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (feedAvailable?.value == false) {
Icon(
painterResource(R.drawable.error_48px),
null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
Text(
"Feed could not be loaded.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}
@SuppressLint("ModifierFactoryExtensionFunction")
override fun homePageModifier(
state: LauncherScaffoldState,
defaultModifier: Modifier
): Modifier {
return defaultModifier.alpha(1f - state.currentProgress)
}
@SuppressLint("ModifierFactoryExtensionFunction")
override fun searchBarModifier(
state: LauncherScaffoldState,
defaultModifier: Modifier
): Modifier {
return defaultModifier
.offset { state.currentOffset.toIntOffset() }
.alpha(1f - state.currentProgress)
}
}

View File

@@ -791,17 +791,13 @@ internal class LauncherScaffoldState(
}
}
private val backInterpolation = PathInterpolator(0f, 0f, 0f, 1f)
fun onPredictiveBack(progress: Float) {
if (!isSettledOnSecondaryPage) return
fun setProgress(progress: Float) {
val gesture = currentGesture ?: return
val anim = currentAnimation ?: return
val progress = backInterpolation.getInterpolation(progress)
when (gesture) {
Gesture.TapSearchBar, Gesture.DoubleTap, Gesture.LongPress -> {
currentZOffset = 1f - progress
currentZOffset = progress
}
else -> {
@@ -818,13 +814,13 @@ internal class LauncherScaffoldState(
currentOffset = if (anim == ScaffoldAnimation.Push) {
Offset(
x * size.width * (1f - progress * 0.1f),
y * size.height * (1f - progress * 0.1f)
x * size.width * progress,
y * size.height * progress
)
} else {
Offset(
x * rubberbandThreshold * progress,
y * rubberbandThreshold * progress
x * rubberbandThreshold * (1f - progress),
y * rubberbandThreshold * (1f - progress)
)
}
@@ -832,6 +828,21 @@ internal class LauncherScaffoldState(
}
}
private val backInterpolation = PathInterpolator(0f, 0f, 0f, 1f)
fun onPredictiveBack(progress: Float) {
if (!isSettledOnSecondaryPage) return
val anim = currentAnimation ?: return
val progress = backInterpolation.getInterpolation(progress)
val p = when(anim) {
ScaffoldAnimation.Push -> 1f - progress * 0.1f
else -> 1f - progress
}
setProgress(p)
}
suspend fun onPredictiveBackCancel() {
val gesture = currentGesture ?: return
val anim = currentAnimation ?: return

View File

@@ -70,6 +70,8 @@ import de.mm20.launcher2.ui.settings.easteregg.EasterEggSettingsRoute
import de.mm20.launcher2.ui.settings.easteregg.EasterEggSettingsScreen
import de.mm20.launcher2.ui.settings.favorites.FavoritesSettingsRoute
import de.mm20.launcher2.ui.settings.favorites.FavoritesSettingsScreen
import de.mm20.launcher2.ui.settings.feed.FeedIntegrationSettingsRoute
import de.mm20.launcher2.ui.settings.feed.FeedIntegrationSettingsScreen
import de.mm20.launcher2.ui.settings.filesearch.FileSearchSettingsRoute
import de.mm20.launcher2.ui.settings.filesearch.FileSearchSettingsScreen
import de.mm20.launcher2.ui.settings.filterbar.FilterBarSettingsRoute
@@ -305,6 +307,9 @@ class SettingsActivity : BaseActivity() {
entry<SmartspacerSettingsRoute> {
SmartspacerSettingsScreen()
}
entry<FeedIntegrationSettingsRoute> {
FeedIntegrationSettingsScreen()
}
}

View File

@@ -0,0 +1,74 @@
package de.mm20.launcher2.ui.settings.feed
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.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavKey
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.LargeMessage
import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import kotlinx.serialization.Serializable
@Serializable
data object FeedIntegrationSettingsRoute : NavKey
@Composable
fun FeedIntegrationSettingsScreen() {
val context = LocalContext.current
val viewModel: FeedIntegrationSettingsScreenVM = viewModel()
val selectedProvider by viewModel.providerPackage.collectAsState(null)
val providers = remember { viewModel.getFeedProviders(context) }
PreferenceScreen(
title = stringResource(R.string.preference_feed_integration),
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/integrations/feed"
) {
if (providers.isEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.fillParentMaxHeight()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LargeMessage(
icon = R.drawable.news_48px,
text = stringResource(R.string.no_feed_providers),
color = MaterialTheme.colorScheme.secondary
)
}
}
} else {
item {
PreferenceCategory {
for (prov in providers) {
Preference(
title = prov.label,
icon = if (prov.packageName == selectedProvider) R.drawable.radio_button_checked_24px else R.drawable.radio_button_unchecked_24px,
onClick = {
viewModel.setProviderPackage(prov.packageName)
}
)
}
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
package de.mm20.launcher2.ui.settings.feed
import android.content.Context
import android.content.Intent
import android.os.Process
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.feed.FeedProvider
import de.mm20.launcher2.feed.FeedService
import de.mm20.launcher2.preferences.feed.FeedSettings
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.shareIn
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.text.Collator
class FeedIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
private val feedService: FeedService by inject()
private val feedSettings: FeedSettings by inject()
val providerPackage = feedSettings.providerPackage
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(0), 1)
fun setProviderPackage(providerPackage: String?) {
feedSettings.setProviderPackage(providerPackage)
}
fun getFeedProviders(context: Context): List<FeedProvider> {
return feedService.getAvailableFeedProviders()
}
}

View File

@@ -61,13 +61,14 @@ fun GestureSettingsScreen() {
add(stringResource(R.string.gesture_action_launch_app) to GestureAction.Launch(null))
}
val optionsWithFeed = options + (stringResource(R.string.gesture_action_feed) to GestureAction.Feed)
val context = LocalContext.current
PreferenceScreen(title = stringResource(R.string.preference_screen_gestures)) {
item {
val appIconSize = 32.dp.toPixels()
PreferenceCategory {
val swipeDown by viewModel.swipeDown.collectAsStateWithLifecycle(null)
val swipeDownApp by viewModel.swipeDownApp.collectAsState(null)
val swipeDownAppIcon by remember(swipeDownApp?.key) {
@@ -128,7 +129,7 @@ fun GestureSettingsScreen() {
icon = R.drawable.swipe_right_alt_24px,
value = swipeRight,
onValueChanged = { viewModel.setSwipeRight(it) },
options = options,
options = optionsWithFeed,
app = swipeRightApp,
appIcon = swipeRightAppIcon,
onAppChanged = { viewModel.setSwipeRightApp(it) }

View File

@@ -11,6 +11,7 @@ import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.locals.LocalBackStack
import de.mm20.launcher2.ui.settings.breezyweather.BreezyWeatherSettingsRoute
import de.mm20.launcher2.ui.settings.feed.FeedIntegrationSettingsRoute
import de.mm20.launcher2.ui.settings.media.MediaIntegrationSettingsRoute
import de.mm20.launcher2.ui.settings.nextcloud.NextcloudSettingsRoute
import de.mm20.launcher2.ui.settings.owncloud.OwncloudSettingsRoute
@@ -45,6 +46,13 @@ fun IntegrationsSettingsScreen() {
backStack.add(MediaIntegrationSettingsRoute)
}
)
Preference(
title = stringResource(R.string.preference_feed_integration),
icon = R.drawable.news_24px,
onClick = {
backStack.add(FeedIntegrationSettingsRoute)
}
)
}
}
item {