Merge branch 'pager' into develop

This commit is contained in:
Shinokuni 2025-02-02 17:27:45 +01:00
commit 15eb463752
16 changed files with 640 additions and 353 deletions

View File

@ -33,6 +33,7 @@ import com.readrops.app.util.Preferences
import com.readrops.db.entities.Feed
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import com.readrops.db.filters.QueryFilters
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -52,7 +53,15 @@ val appModule = module {
factory { AccountScreenModel(get(), androidContext()) }
factory { (itemId: Int) -> ItemScreenModel(get(), itemId, get()) }
factory { (itemId: Int, itemIndex: Int, queryFilters: QueryFilters) ->
ItemScreenModel(
itemId = itemId,
itemIndex = itemIndex,
queryFilters = queryFilters,
database = get(),
preferences = get()
)
}
factory { (accountType: Account, mode: AccountCredentialsScreenMode) ->
AccountCredentialsScreenModel(accountType, mode, get())

View File

@ -1,113 +1,53 @@
package com.readrops.app.item
import android.widget.RelativeLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.children
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import coil3.compose.AsyncImage
import com.readrops.app.R
import com.readrops.app.item.view.ItemNestedScrollView
import com.readrops.app.item.view.ItemWebView
import com.readrops.app.util.components.AndroidScreen
import com.readrops.app.util.components.CenteredProgressIndicator
import com.readrops.app.util.components.FeedIcon
import com.readrops.app.util.components.IconText
import com.readrops.app.util.components.Placeholder
import com.readrops.app.util.extensions.isError
import com.readrops.app.util.extensions.isLoading
import com.readrops.app.util.extensions.openInCustomTab
import com.readrops.app.util.extensions.openUrl
import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.app.util.theme.spacing
import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.util.DateUtils
import com.readrops.db.filters.QueryFilters
import kotlinx.coroutines.flow.distinctUntilChanged
import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
class ItemScreen(
private val itemId: Int
private val itemId: Int,
private val itemIndex: Int,
private val queryFilters: QueryFilters
) : AndroidScreen() {
@Composable
override fun Content() {
val context = LocalContext.current
val density = LocalDensity.current
val navigator = LocalNavigator.currentOrThrow
val screenModel =
koinScreenModel<ItemScreenModel>(parameters = { parametersOf(itemId) })
koinScreenModel<ItemScreenModel>(parameters = { parametersOf(itemId, itemIndex, queryFilters) })
val state by screenModel.state.collectAsStateWithLifecycle()
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundColor = MaterialTheme.colorScheme.background
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
val items = state.itemState.collectAsLazyPagingItems()
val snackbarHostState = remember { SnackbarHostState() }
var isScrollable by remember { mutableStateOf(true) }
var refreshAndroidView by remember { mutableStateOf(true) }
// https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view
val bottomBarHeight = 64.dp
val bottomBarHeightPx = with(density) { bottomBarHeight.roundToPx().toFloat() }
val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = bottomBarOffsetHeightPx.floatValue + delta
bottomBarOffsetHeightPx.floatValue = newOffset.coerceIn(-bottomBarHeightPx, 0f)
return Offset.Zero
}
}
}
if (state.imageDialogUrl != null) {
ItemImageDialog(
@ -116,7 +56,6 @@ class ItemScreen(
screenModel.shareImage(state.imageDialogUrl!!, context)
} else {
screenModel.downloadImage(state.imageDialogUrl!!, context)
}
screenModel.closeImageDialog()
@ -137,254 +76,75 @@ class ItemScreen(
}
}
if (state.itemWithFeed != null) {
val itemWithFeed = state.itemWithFeed!!
val item = itemWithFeed.item
val accentColor = if (itemWithFeed.color != 0) {
Color(itemWithFeed.color)
} else {
primaryColor
when {
items.isLoading() -> {
CenteredProgressIndicator()
}
fun openUrl(url: String) {
if (state.openInExternalBrowser) {
context.openUrl(url)
} else {
context.openInCustomTab(url, state.theme, accentColor)
}
}
Scaffold(
modifier = Modifier.nestedScroll(nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
ItemScreenBottomBar(
state = state.bottomBarState,
accentColor = accentColor,
modifier = Modifier
.navigationBarsPadding()
.height(bottomBarHeight)
.offset {
if (isScrollable) {
IntOffset(
x = 0,
y = -bottomBarOffsetHeightPx.floatValue.roundToInt()
)
} else {
IntOffset(0, 0)
}
},
onShare = { screenModel.shareItem(item, context) },
onOpenUrl = { openUrl(item.link!!) },
onChangeReadState = {
screenModel.setItemReadState(item.apply { isRead = it })
},
onChangeStarState = {
screenModel.setItemStarState(item.apply { isStarred = it })
}
)
}
) { paddingValues ->
Box(
modifier = Modifier.padding(paddingValues)
) {
AndroidView(
factory = { context ->
ItemNestedScrollView(
context = context,
useBackgroundTitle = item.imageLink != null,
onGlobalLayoutListener = { viewHeight, contentHeight ->
isScrollable = viewHeight - contentHeight < 0
},
onUrlClick = { url -> openUrl(url) },
onImageLongPress = { url -> screenModel.openImageDialog(url) }
) {
if (item.imageLink != null) {
BackgroundTitle(itemWithFeed = itemWithFeed)
} else {
Box {
IconButton(
onClick = { navigator.pop() },
modifier = Modifier
.statusBarsPadding()
.align(Alignment.TopStart)
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null,
)
}
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = accentColor,
accentColor = accentColor,
baseColor = MaterialTheme.colorScheme.onBackground,
bottomPadding = true
)
}
}
}
},
update = { nestedScrollView ->
if (refreshAndroidView) {
val relativeLayout =
(nestedScrollView.children.toList()[0] as RelativeLayout)
val webView = relativeLayout.children.toList()[1] as ItemWebView
webView.loadText(
itemWithFeed = itemWithFeed,
accentColor = accentColor,
backgroundColor = backgroundColor,
onBackgroundColor = onBackgroundColor
)
refreshAndroidView = false
}
}
)
}
}
} else {
CenteredProgressIndicator()
}
}
}
@Composable
fun BackgroundTitle(
itemWithFeed: ItemWithFeed,
) {
val navigator = LocalNavigator.currentOrThrow
val onScrimColor = Color.White.copy(alpha = 0.85f)
val accentColor = if (itemWithFeed.color != 0) {
Color(itemWithFeed.color)
} else {
onScrimColor
}
Surface(
shape = RoundedCornerShape(
bottomStart = 24.dp,
bottomEnd = 24.dp
),
modifier = Modifier.height(IntrinsicSize.Max)
) {
AsyncImage(
model = itemWithFeed.item.imageLink,
contentDescription = null,
contentScale = ContentScale.Crop,
error = painterResource(id = R.drawable.ic_broken_image),
modifier = Modifier
.fillMaxSize()
)
Surface(
color = Color.Black.copy(alpha = 0.6f),
modifier = Modifier
.fillMaxSize()
) {
Box {
IconButton(
onClick = { navigator.pop() },
modifier = Modifier
.statusBarsPadding()
.align(Alignment.TopStart)
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = onScrimColor,
accentColor = accentColor,
baseColor = onScrimColor,
bottomPadding = true
items.isError() -> {
Placeholder(
text = stringResource(R.string.error_occured),
painter = painterResource(id = R.drawable.ic_error)
)
}
else -> {
val pagerState = rememberPagerState(
initialPage = if (itemIndex > -1) itemIndex else 0,
pageCount = { items.itemCount }
)
LaunchedEffect(pagerState.currentPage) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect { pageIndex ->
items[pageIndex]?.let {
if (!it.isRead) {
screenModel.setItemReadState(it.item.apply { isRead = true })
}
}
}
}
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 2,
key = items.itemKey { it.item.id }
) { page ->
val itemWithFeed = items[page]
if (itemWithFeed != null) {
val accentColor = if (itemWithFeed.color != 0) {
Color(itemWithFeed.color)
} else {
MaterialTheme.colorScheme.primary
}
val item = itemWithFeed.item
ItemScreenPage(
itemWithFeed = itemWithFeed,
snackbarHostState = snackbarHostState,
onOpenUrl = { url ->
if (state.openInExternalBrowser) {
context.openUrl(url)
} else {
context.openInCustomTab(url, state.theme, accentColor)
}
},
onShareItem = { screenModel.shareItem(item, context) },
onSetReadState = {
screenModel.setItemReadState(item.apply { isRead = it })
},
onSetStarState = {
screenModel.setItemStarState(item.apply { isStarred = it })
},
onOpenImageDialog = { screenModel.openImageDialog(it) },
onPop = { navigator.pop() },
)
}
}
}
}
}
MediumSpacer()
}
@Composable
fun SimpleTitle(
itemWithFeed: ItemWithFeed,
titleColor: Color,
accentColor: Color,
baseColor: Color,
bottomPadding: Boolean,
) {
val item = itemWithFeed.item
val spacing = MaterialTheme.spacing.mediumSpacing
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(
start = spacing,
end = spacing,
top = spacing,
bottom = if (bottomPadding) spacing else 0.dp
)
) {
FeedIcon(
iconUrl = itemWithFeed.feedIconUrl,
name = itemWithFeed.feedName,
size = 48.dp,
modifier = Modifier.clip(CircleShape)
)
ShortSpacer()
Text(
text = itemWithFeed.feedName,
style = MaterialTheme.typography.labelLarge,
color = baseColor,
textAlign = TextAlign.Center
)
ShortSpacer()
Text(
text = item.title!!,
style = MaterialTheme.typography.headlineMedium,
color = titleColor,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
if (item.author != null) {
ShortSpacer()
IconText(
icon = painterResource(id = R.drawable.ic_person),
text = itemWithFeed.item.author!!,
style = MaterialTheme.typography.labelMedium,
color = baseColor,
tint = accentColor
)
}
ShortSpacer()
val readTime = if (item.readTime > 1) {
stringResource(id = R.string.read_time, item.readTime.roundToInt())
} else {
stringResource(id = R.string.read_time_lower_than_1)
}
Text(
text = "${DateUtils.formattedDate(item.pubDate!!)} ${stringResource(id = R.string.interpoint)} $readTime",
style = MaterialTheme.typography.labelMedium,
color = baseColor
)
}
}

View File

@ -10,6 +10,10 @@ import android.net.Uri
import android.os.Environment
import androidx.compose.runtime.Stable
import androidx.core.content.FileProvider
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import coil3.imageLoader
@ -18,18 +22,24 @@ import coil3.request.allowHardware
import coil3.toBitmap
import com.readrops.app.R
import com.readrops.app.repositories.BaseRepository
import com.readrops.app.util.PAGING_PAGE_SIZE
import com.readrops.app.util.PAGING_PREFETCH_DISTANCE
import com.readrops.app.util.Preferences
import com.readrops.db.Database
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import com.readrops.db.filters.QueryFilters
import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.queries.ItemSelectionQueryBuilder
import com.readrops.db.queries.ItemsQueryBuilder
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -38,10 +48,11 @@ import org.koin.core.parameter.parametersOf
import java.io.File
import java.net.URI
@OptIn(ExperimentalCoroutinesApi::class)
class ItemScreenModel(
private val database: Database,
private val itemId: Int,
private val itemIndex: Int,
private val queryFilters: QueryFilters,
private val database: Database,
private val preferences: Preferences,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<ItemState>(ItemState()), KoinComponent {
@ -53,9 +64,11 @@ class ItemScreenModel(
init {
screenModelScope.launch(dispatcher) {
database.accountDao().selectCurrentAccount()
.flatMapLatest { account ->
.collect { account ->
this@ItemScreenModel.account = account!!
// With Fever, we notify directly the server about state changes
// so we need account credentials
if (account.type == AccountType.FEVER) {
get<SharedPreferences>().apply {
account.login = getString(account.loginKey, null)
@ -65,22 +78,21 @@ class ItemScreenModel(
repository = get { parametersOf(account) }
val query = ItemSelectionQueryBuilder.buildQuery(
itemId = itemId,
separateState = account.config.useSeparateState
)
database.itemDao().selectItemById(query)
}
.collect { itemWithFeed ->
mutableState.update {
it.copy(
itemWithFeed = itemWithFeed,
bottomBarState = BottomBarState(
isRead = itemWithFeed.item.isRead,
isStarred = itemWithFeed.item.isStarred
)
if (itemIndex > -1) {
mutableState.update { it.copy(itemState = buildPager()) }
} else {
val query = ItemSelectionQueryBuilder.buildQuery(
itemId = itemId,
separateState = account.config.useSeparateState
)
database.itemDao().selectItemById(query)
.distinctUntilChanged()
.collect { itemWithFeed ->
mutableState.update {
it.copy(itemState = flowOf(PagingData.from(listOf(itemWithFeed))))
}
}
}
}
}
@ -105,6 +117,27 @@ class ItemScreenModel(
}
}
private fun buildPager(): Flow<PagingData<ItemWithFeed>> {
val query = ItemsQueryBuilder.buildItemsQuery(
queryFilters = queryFilters,
separateState = account.config.useSeparateState
)
val pageNb = (((itemIndex + PAGING_PAGE_SIZE - 1) / PAGING_PAGE_SIZE) + 1)
.coerceAtLeast(1)
return Pager(
config = PagingConfig(
initialLoadSize = PAGING_PAGE_SIZE * pageNb,
pageSize = PAGING_PAGE_SIZE,
prefetchDistance = PAGING_PREFETCH_DISTANCE
),
pagingSourceFactory = { database.itemDao().selectAll(query) }
)
.flow
.cachedIn(screenModelScope)
}
fun shareItem(item: Item, context: Context) {
Intent().apply {
action = Intent.ACTION_SEND
@ -213,11 +246,10 @@ class ItemScreenModel(
@Stable
data class ItemState(
val itemWithFeed: ItemWithFeed? = null,
val bottomBarState: BottomBarState = BottomBarState(),
val itemState: Flow<PagingData<ItemWithFeed>> = emptyFlow(),
val imageDialogUrl: String? = null,
val fileDownloadedEvent: Boolean = false,
val openInExternalBrowser: Boolean = false,
val theme: String? = "",
val error: String? = null
val error: String? = null,
)

View File

@ -0,0 +1,163 @@
package com.readrops.app.item
import android.widget.RelativeLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
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.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.children
import com.readrops.app.item.components.BackgroundTitle
import com.readrops.app.item.components.BottomBarState
import com.readrops.app.item.components.ItemScreenBottomBar
import com.readrops.app.item.components.SimpleTitle
import com.readrops.app.item.components.rememberBottomBarNestedScrollConnection
import com.readrops.app.item.view.ItemNestedScrollView
import com.readrops.app.item.view.ItemWebView
import com.readrops.db.pojo.ItemWithFeed
@Composable
fun ItemScreenPage(
itemWithFeed: ItemWithFeed,
snackbarHostState: SnackbarHostState,
onOpenUrl: (String) -> Unit,
onShareItem: () -> Unit,
onSetReadState: (Boolean) -> Unit,
onSetStarState: (Boolean) -> Unit,
onOpenImageDialog: (String) -> Unit,
onPop: () -> Unit,
modifier: Modifier = Modifier
) {
val item = itemWithFeed.item
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundColor = MaterialTheme.colorScheme.background
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
val accentColor = if (itemWithFeed.color != 0) {
Color(itemWithFeed.color)
} else {
primaryColor
}
val nestedScrollConnection = rememberBottomBarNestedScrollConnection()
var refreshAndroidView by remember { mutableStateOf(true) }
var isScrollable by remember { mutableStateOf(true) }
Scaffold(
modifier = modifier.nestedScroll(nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
ItemScreenBottomBar(
state = BottomBarState(
isRead = itemWithFeed.item.isRead,
isStarred = itemWithFeed.item.isStarred
),
accentColor = accentColor,
modifier = Modifier
.navigationBarsPadding()
.height(nestedScrollConnection.bottomBarHeight)
.offset {
if (isScrollable) {
IntOffset(
x = 0,
y = -nestedScrollConnection.bottomBarOffset
)
} else {
IntOffset(0, 0)
}
},
onShare = onShareItem,
onOpenUrl = { onOpenUrl(item.link!!) },
onChangeReadState = onSetReadState,
onChangeStarState = onSetStarState
)
}
) { paddingValues ->
Box(
modifier = Modifier.padding(paddingValues)
) {
AndroidView(
factory = { context ->
ItemNestedScrollView(
context = context,
useBackgroundTitle = item.imageLink != null,
onGlobalLayoutListener = { viewHeight, contentHeight ->
isScrollable = viewHeight - contentHeight < 0
},
onUrlClick = { url -> onOpenUrl(url) },
onImageLongPress = { url -> onOpenImageDialog(url) }
) {
if (item.imageLink != null) {
BackgroundTitle(
itemWithFeed = itemWithFeed,
onClickBack = onPop
)
} else {
Box {
IconButton(
onClick = onPop,
modifier = Modifier
.statusBarsPadding()
.align(Alignment.TopStart)
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null,
)
}
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = accentColor,
accentColor = accentColor,
baseColor = MaterialTheme.colorScheme.onBackground,
bottomPadding = true
)
}
}
}
},
update = { nestedScrollView ->
if (refreshAndroidView) {
val relativeLayout =
(nestedScrollView.children.toList()
.first() as RelativeLayout)
val webView =
relativeLayout.children.toList()[1] as ItemWebView
webView.loadText(
itemWithFeed = itemWithFeed,
accentColor = accentColor,
backgroundColor = backgroundColor,
onBackgroundColor = onBackgroundColor
)
refreshAndroidView = false
}
}
)
}
}
}

View File

@ -0,0 +1,99 @@
package com.readrops.app.item.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.readrops.app.R
import com.readrops.app.timelime.components.itemWithFeed
import com.readrops.app.util.DefaultPreview
import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.ReadropsTheme
import com.readrops.db.pojo.ItemWithFeed
@Composable
fun BackgroundTitle(
itemWithFeed: ItemWithFeed,
onClickBack: () -> Unit,
) {
val onScrimColor = Color.White.copy(alpha = 0.85f)
val accentColor = if (itemWithFeed.color != 0) {
Color(itemWithFeed.color)
} else {
onScrimColor
}
Surface(
shape = RoundedCornerShape(
bottomStart = 24.dp,
bottomEnd = 24.dp
),
modifier = Modifier.height(IntrinsicSize.Max)
) {
AsyncImage(
model = itemWithFeed.item.imageLink,
contentDescription = null,
contentScale = ContentScale.Crop,
error = painterResource(id = R.drawable.ic_broken_image),
modifier = Modifier
.fillMaxSize()
)
Surface(
color = Color.Black.copy(alpha = 0.6f),
modifier = Modifier
.fillMaxSize()
) {
Box {
IconButton(
onClick = onClickBack,
modifier = Modifier
.statusBarsPadding()
.align(Alignment.TopStart)
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = onScrimColor,
accentColor = accentColor,
baseColor = onScrimColor,
bottomPadding = true
)
}
}
}
MediumSpacer()
}
@DefaultPreview
@Composable
private fun BackgroundTitlePreview() {
ReadropsTheme {
BackgroundTitle(
itemWithFeed = itemWithFeed,
onClickBack = {}
)
}
}

View File

@ -0,0 +1,38 @@
package com.readrops.app.item.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun rememberBottomBarNestedScrollConnection(density: Density = LocalDensity.current) =
remember { BottomBarNestedScrollConnection(density) }
class BottomBarNestedScrollConnection(
density: Density,
val bottomBarHeight: Dp = 64.dp,
) : NestedScrollConnection {
private val bottomBarHeightPx = with(density) { bottomBarHeight.roundToPx().toFloat() }
var bottomBarOffset: Int by mutableIntStateOf(0)
private set
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = bottomBarOffset.toFloat() + delta
bottomBarOffset = newOffset.coerceIn(-bottomBarHeightPx, 0f).roundToInt()
return Offset.Zero
}
}

View File

@ -1,4 +1,4 @@
package com.readrops.app.item
package com.readrops.app.item.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@ -16,7 +16,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import com.readrops.app.R
import com.readrops.app.util.DefaultPreview
import com.readrops.app.util.FeedColors
import com.readrops.app.util.theme.ReadropsTheme
import com.readrops.app.util.theme.spacing
data class BottomBarState(
@ -97,3 +99,21 @@ fun ItemScreenBottomBar(
}
}
}
@DefaultPreview
@Composable
private fun ItemScreenBottomBarPreview() {
ReadropsTheme {
ItemScreenBottomBar(
state = BottomBarState(
isRead = false,
isStarred = false
),
accentColor = MaterialTheme.colorScheme.primary,
onShare = {},
onOpenUrl = {},
onChangeReadState = {},
onChangeStarState = {},
)
}
}

View File

@ -0,0 +1,117 @@
package com.readrops.app.item.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.readrops.app.R
import com.readrops.app.timelime.components.itemWithFeed
import com.readrops.app.util.DefaultPreview
import com.readrops.app.util.components.FeedIcon
import com.readrops.app.util.components.IconText
import com.readrops.app.util.theme.ReadropsTheme
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.app.util.theme.spacing
import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.util.DateUtils
import kotlin.math.roundToInt
@Composable
fun SimpleTitle(
itemWithFeed: ItemWithFeed,
titleColor: Color,
accentColor: Color,
baseColor: Color,
bottomPadding: Boolean,
) {
val item = itemWithFeed.item
val spacing = MaterialTheme.spacing.mediumSpacing
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(
start = spacing,
end = spacing,
top = spacing,
bottom = if (bottomPadding) spacing else 0.dp
)
) {
FeedIcon(
iconUrl = itemWithFeed.feedIconUrl,
name = itemWithFeed.feedName,
size = 48.dp,
modifier = Modifier.clip(CircleShape)
)
ShortSpacer()
Text(
text = itemWithFeed.feedName,
style = MaterialTheme.typography.labelLarge,
color = baseColor,
textAlign = TextAlign.Center
)
ShortSpacer()
Text(
text = item.title!!,
style = MaterialTheme.typography.headlineMedium,
color = titleColor,
textAlign = TextAlign.Center,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
if (item.author != null) {
ShortSpacer()
IconText(
icon = painterResource(id = R.drawable.ic_person),
text = itemWithFeed.item.author!!,
style = MaterialTheme.typography.labelMedium,
color = baseColor,
tint = accentColor
)
}
ShortSpacer()
val readTime = if (item.readTime > 1) {
stringResource(id = R.string.read_time, item.readTime.roundToInt())
} else {
stringResource(id = R.string.read_time_lower_than_1)
}
Text(
text = "${DateUtils.formattedDate(item.pubDate!!)} ${stringResource(id = R.string.interpoint)} $readTime",
style = MaterialTheme.typography.labelMedium,
color = baseColor
)
}
}
@DefaultPreview
@Composable
private fun SimpleTitlePreview() {
ReadropsTheme {
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = MaterialTheme.colorScheme.primary,
accentColor = MaterialTheme.colorScheme.primary,
baseColor = MaterialTheme.colorScheme.onBackground,
bottomPadding = true
)
}
}

View File

@ -22,6 +22,7 @@ class ItemNestedScrollView(
addView(
RelativeLayout(context).apply {
ViewCompat.setNestedScrollingEnabled(this, true)
descendantFocusability = FOCUS_BLOCK_DESCENDANTS
val composeView = ComposeView(context).apply {
id = 1

View File

@ -14,6 +14,9 @@ import com.readrops.app.repositories.ErrorResult
import com.readrops.app.repositories.GetFoldersWithFeeds
import com.readrops.app.sync.SyncWorker
import com.readrops.app.timelime.components.TimelineItemSize
import com.readrops.app.util.PAGING_INITIAL_SIZE
import com.readrops.app.util.PAGING_PAGE_SIZE
import com.readrops.app.util.PAGING_PREFETCH_DISTANCE
import com.readrops.app.util.Preferences
import com.readrops.app.util.extensions.clearSerializables
import com.readrops.app.util.extensions.getSerializable
@ -167,9 +170,9 @@ class TimelineScreenModel(
val pager = Pager(
config = PagingConfig(
initialLoadSize = 50,
pageSize = 50,
prefetchDistance = 15
initialLoadSize = PAGING_INITIAL_SIZE,
pageSize = PAGING_PAGE_SIZE,
prefetchDistance = PAGING_PREFETCH_DISTANCE
),
pagingSourceFactory = {
database.itemDao().selectAll(query)

View File

@ -69,6 +69,7 @@ import com.readrops.app.util.extensions.openUrl
import com.readrops.app.util.theme.spacing
import com.readrops.db.entities.OpenIn
import com.readrops.db.filters.MainFilter
import com.readrops.db.filters.QueryFilters
import com.readrops.db.pojo.ItemWithFeed
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.filter
@ -118,7 +119,16 @@ object TimelineTab : Tab {
openItemChannel.receiveAsFlow()
.collect { itemId ->
screenModel.selectItemWithFeed(itemId)
?.let { openItem(it, preferences, navigator, context) }
?.let {
openItem(
itemWithFeed = it,
itemIndex = items.itemSnapshotList.indexOf(it),
queryFilters = state.filters,
preferences = preferences,
navigator = navigator,
context = context
)
}
}
}
@ -210,6 +220,8 @@ object TimelineTab : Tab {
onOpenItem = { itemWithFeed, openIn ->
openItem(
itemWithFeed = itemWithFeed,
itemIndex = items.itemSnapshotList.indexOf(itemWithFeed),
queryFilters = state.filters,
openIn = openIn,
preferences = preferences,
navigator = navigator,
@ -318,6 +330,8 @@ object TimelineTab : Tab {
openItem(
itemWithFeed = itemWithFeed,
itemIndex = index,
queryFilters = state.filters,
preferences = preferences,
navigator = navigator,
context = context
@ -363,6 +377,8 @@ object TimelineTab : Tab {
private fun openItem(
itemWithFeed: ItemWithFeed,
itemIndex: Int,
queryFilters: QueryFilters,
preferences: TimelinePreferences,
navigator: Navigator,
context: Context,
@ -371,7 +387,7 @@ object TimelineTab : Tab {
val url = itemWithFeed.item.link!!
if (openIn == OpenIn.LOCAL_VIEW) {
navigator.push(ItemScreen(itemWithFeed.item.id))
navigator.push(ItemScreen(itemWithFeed.item.id, itemIndex, queryFilters))
} else {
if (preferences.openInExternalBrowser) {
context.openUrl(url)

View File

@ -151,7 +151,7 @@ fun TimelineItem(
}
}
private val itemWithFeed = ItemWithFeed(
val itemWithFeed = ItemWithFeed(
item = com.readrops.db.entities.Item(
title = "This is a not so long item title",
pubDate = LocalDateTime.now(),

View File

@ -0,0 +1,7 @@
package com.readrops.app.util
const val PAGING_INITIAL_SIZE = 50
const val PAGING_PAGE_SIZE = 50
const val PAGING_PREFETCH_DISTANCE = 15

View File

@ -1,5 +1,7 @@
package com.readrops.db.filters
import java.io.Serializable
enum class MainFilter {
STARS,
NEW,
@ -31,4 +33,4 @@ data class QueryFilters(
val subFilter: SubFilter = SubFilter.ALL,
val orderField: OrderField = OrderField.DATE,
val orderType: OrderType = OrderType.DESC,
)
) : Serializable

View File

@ -23,4 +23,23 @@ data class ItemWithFeed(
@ColumnInfo(name = "is_read") val isRead: Boolean = false,
@ColumnInfo(name = "open_in") val openIn: OpenIn?,
@ColumnInfo(name = "open_in_ask") val openInAsk: Boolean = true
)
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ItemWithFeed
return item.id == other.item.id
&& isRead == other.isRead
&& isStarred == other.isStarred
}
override fun hashCode(): Int {
var result = item.id.hashCode()
result = 31 * result + isStarred.hashCode()
result = 31 * result + isRead.hashCode()
return result
}
}

View File

@ -15,6 +15,7 @@ object ItemsQueryBuilder {
"Item.remote_id",
"title",
"clean_description",
"content",
"image_link",
"pub_date",
"link",