Compare commits

...

4 Commits

Author SHA1 Message Date
Shinokuni d486bd92f9 Use Inter custom font in ItemWebView 2024-04-13 15:58:16 +02:00
Shinokuni 7b644cbc97 Open webView urls in external navigator 2024-04-13 14:24:06 +02:00
Shinokuni 02a3f82b72 Extract views from ItemScreen 2024-04-13 14:12:55 +02:00
Shinokuni 91378f0a54 Add collapsable bottom bar in ItemScreen
Gather all actions at the bottom of the screen:
* set read state
* set start state
* share url
* open url
2024-04-13 13:03:37 +02:00
14 changed files with 440 additions and 82 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,41 +1,52 @@
package com.readrops.app.compose.item
import android.util.Base64
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.background
import android.content.Intent
import android.net.Uri
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.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
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.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 cafe.adriel.voyager.koin.getScreenModel
import coil.compose.AsyncImage
import com.readrops.api.utils.DateUtils
import com.readrops.app.compose.R
import com.readrops.app.compose.util.Utils
import com.readrops.app.compose.item.view.ItemNestedScrollView
import com.readrops.app.compose.item.view.ItemWebView
import com.readrops.app.compose.util.components.AndroidScreen
import com.readrops.app.compose.util.components.CenteredProgressIndicator
import com.readrops.app.compose.util.components.IconText
@ -47,90 +58,136 @@ import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt
class ItemScreen(
private val itemId: Int,
private val itemId: Int
) : AndroidScreen() {
@Composable
override fun Content() {
val context = LocalContext.current
val density = LocalDensity.current
val screenModel =
getScreenModel<ItemScreenModel>(parameters = { parametersOf(itemId) })
val scrollState = rememberScrollState()
val state by screenModel.state.collectAsStateWithLifecycle()
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundColor = MaterialTheme.colorScheme.background
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.background)
.verticalScroll(scrollState)
) {
if (state.itemWithFeed != null) {
val itemWithFeed = state.itemWithFeed!!
var isScrollable by remember { mutableStateOf(true) }
if (itemWithFeed.item.imageLink != null) {
BackgroundTitle(
itemWithFeed = itemWithFeed
)
} else {
val tintColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
MaterialTheme.colorScheme.onBackground
}
// 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) }
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = tintColor,
accentColor = tintColor,
baseColor = MaterialTheme.colorScheme.onBackground,
bottomPadding = true
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.itemWithFeed != null) {
val itemWithFeed = state.itemWithFeed!!
val item = itemWithFeed.item
val accentColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
primaryColor
}
fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
}
Scaffold(
modifier = Modifier
.nestedScroll(nestedScrollConnection),
bottomBar = {
ItemScreenBottomBar(
item = item,
accentColor = accentColor,
modifier = Modifier
.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,
onGlobalLayoutListener = { viewHeight, contentHeight ->
isScrollable = viewHeight - contentHeight < 0
},
onUrlClick = { url -> openUrl(url) }
) {
if (item.imageLink != null) {
BackgroundTitle(
itemWithFeed = itemWithFeed
)
} else {
val tintColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
MaterialTheme.colorScheme.onBackground
}
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = WebViewClient()
SimpleTitle(
itemWithFeed = itemWithFeed,
titleColor = tintColor,
accentColor = tintColor,
baseColor = MaterialTheme.colorScheme.onBackground,
bottomPadding = true
)
}
}
},
update = { nestedScrollView ->
val relativeLayout =
(nestedScrollView.children.toList()[0] as RelativeLayout)
val webView = relativeLayout.children.toList()[1] as ItemWebView
settings.builtInZoomControls = true
settings.displayZoomControls = false
settings.setSupportZoom(false)
isVerticalScrollBarEnabled = false
setBackgroundColor(backgroundColor.toArgb())
webView.loadText(
itemWithFeed = itemWithFeed,
accentColor = accentColor,
backgroundColor = backgroundColor,
onBackgroundColor = onBackgroundColor
)
}
},
update = { webView ->
val tintColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
primaryColor
}
val string = context.getString(
R.string.webview_html_template,
Utils.getCssColor(tintColor.toArgb()),
Utils.getCssColor(onBackgroundColor.toArgb()),
Utils.getCssColor(backgroundColor.toArgb()),
screenModel.formatText()
)
val data =
Base64.encodeToString(string.encodeToByteArray(), Base64.NO_PADDING)
webView.loadData(data, "text/html; charset=utf-8", "base64")
}
)
} else {
CenteredProgressIndicator()
)
}
}
} else {
CenteredProgressIndicator()
}
}
}
@ -215,7 +272,8 @@ fun SimpleTitle(
Text(
text = itemWithFeed.feedName,
style = MaterialTheme.typography.labelLarge,
color = baseColor
color = baseColor,
textAlign = TextAlign.Center
)
ShortSpacer()

View File

@ -0,0 +1,84 @@
package com.readrops.app.compose.item
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import com.readrops.app.compose.R
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Item
@Composable
fun ItemScreenBottomBar(
item: Item,
accentColor: Color,
onShare: () -> Unit,
onOpenUrl: () -> Unit,
onChangeReadState: (Boolean) -> Unit,
onChangeStarState: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = accentColor,
modifier = modifier.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
) {
IconButton(
onClick = { onChangeReadState(!item.isRead) }
) {
Icon(
painter = painterResource(
id = if (item.isRead)
R.drawable.ic_remove_done
else R.drawable.ic_done_all
),
contentDescription = null
)
}
IconButton(
onClick = { onChangeStarState(!item.isStarred) }
) {
Icon(
painter = painterResource(
id = if (item.isStarred)
R.drawable.ic_star
else R.drawable.ic_star_outline
),
contentDescription = null
)
}
IconButton(
onClick = onShare
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = null
)
}
IconButton(
onClick = onOpenUrl
) {
Icon(
painter = painterResource(id = R.drawable.ic_open_in_browser),
contentDescription = null
)
}
}
}
}

View File

@ -1,31 +1,55 @@
package com.readrops.app.compose.item
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Stable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.db.Database
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.queries.ItemSelectionQueryBuilder
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
class ItemScreenModel(
private val database: Database,
private val itemId: Int
) : StateScreenModel<ItemState>(ItemState()) {
private val itemId: Int,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<ItemState>(ItemState()), KoinComponent {
//TODO Is this really the best solution?
lateinit var account: Account
lateinit var repository: BaseRepository
init {
screenModelScope.launch(Dispatchers.IO) {
mutableState.update {
val query = ItemSelectionQueryBuilder.buildQuery(itemId, false)
screenModelScope.launch(dispatcher) {
database.newAccountDao().selectCurrentAccount()
.collect { account ->
this@ItemScreenModel.account = account!!
repository = get { parametersOf(account) }
it.copy(
itemWithFeed = database.newItemDao().selectItemById(query)
)
}
val query = ItemSelectionQueryBuilder.buildQuery(
itemId = itemId,
separateState = account.config.useSeparateState
)
database.newItemDao().selectItemById(query)
.collect { itemWithFeed ->
mutableState.update {
it.copy(itemWithFeed = itemWithFeed)
}
}
}
}
}
@ -41,6 +65,30 @@ class ItemScreenModel(
document.select("div,span").forEach { it.clearAttributes() }
return document.body().html()
}
fun shareItem(item: Item, context: Context) {
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, item.link)
}.also {
context.startActivity(Intent.createChooser(it, null))
}
}
fun setItemReadState(item: Item) {
//TODO support separateState
screenModelScope.launch(dispatcher) {
repository.setItemReadState(item)
}
}
fun setItemStarState(item: Item) {
//TODO support separateState
screenModelScope.launch(dispatcher) {
repository.setItemStarState(item)
}
}
}
@Stable

View File

@ -0,0 +1,67 @@
package com.readrops.app.compose.item.view
import android.annotation.SuppressLint
import android.content.Context
import android.widget.RelativeLayout
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.ViewCompat
import androidx.core.widget.NestedScrollView
@SuppressLint("ResourceType", "ViewConstructor")
class ItemNestedScrollView(
context: Context,
onGlobalLayoutListener: (viewHeight: Int, contentHeight: Int) -> Unit,
onUrlClick: (String) -> Unit,
composeViewContent: @Composable () -> Unit
) : NestedScrollView(context) {
init {
addView(
RelativeLayout(context).apply {
ViewCompat.setNestedScrollingEnabled(this, true)
val composeView = ComposeView(context).apply {
id = 1
setContent {
composeViewContent()
}
}
val composeViewParams = RelativeLayout.LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT
)
composeViewParams.addRule(RelativeLayout.CENTER_HORIZONTAL)
composeView.layoutParams = composeViewParams
val webView = ItemWebView(
context = context,
onUrlClick = onUrlClick
).apply {
id = 2
ViewCompat.setNestedScrollingEnabled(this, true)
}
val webViewParams = RelativeLayout.LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT
)
webViewParams.addRule(RelativeLayout.BELOW, composeView.id)
webView.layoutParams = webViewParams
addView(composeView)
addView(webView)
}
)
viewTreeObserver.addOnGlobalLayoutListener {
val viewHeight = this.measuredHeight
val contentHeight = getChildAt(0).height
onGlobalLayoutListener(viewHeight, contentHeight)
}
}
}

View File

@ -0,0 +1,76 @@
package com.readrops.app.compose.item.view
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.readrops.app.compose.R
import com.readrops.app.compose.util.Utils
import com.readrops.db.pojo.ItemWithFeed
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
@SuppressLint("SetJavaScriptEnabled", "ViewConstructor")
class ItemWebView(
context: Context,
onUrlClick: (String) -> Unit,
attrs: AttributeSet? = null,
) : WebView(context, attrs) {
init {
settings.javaScriptEnabled = true
settings.builtInZoomControls = true
settings.displayZoomControls = false
settings.setSupportZoom(false)
isVerticalScrollBarEnabled = false
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
url?.let { onUrlClick(it) }
return true
}
}
}
fun loadText(
itemWithFeed: ItemWithFeed,
accentColor: Color,
backgroundColor: Color,
onBackgroundColor: Color
) {
val string = context.getString(
R.string.webview_html_template,
Utils.getCssColor(accentColor.toArgb()),
Utils.getCssColor(onBackgroundColor.toArgb()),
Utils.getCssColor(backgroundColor.toArgb()),
formatText(itemWithFeed)
)
loadDataWithBaseURL(
"file:///android_asset/",
string,
"text/html; charset=utf-8",
"UTF-8",
null
)
}
private fun formatText(itemWithFeed: ItemWithFeed): String {
return if (itemWithFeed.item.content != null) {
val document = if (itemWithFeed.websiteUrl != null) Jsoup.parse(
Parser.unescapeEntities(itemWithFeed.item.text, false), itemWithFeed.websiteUrl
) else Jsoup.parse(
Parser.unescapeEntities(itemWithFeed.item.text, false)
)
document.select("div,span").forEach { it.clearAttributes() }
return document.body().html()
} else {
""
}
}
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M1.79,12l5.58,5.59L5.96,19 0.37,13.41 1.79,12zM2.24,4.22L12.9,14.89l-1.28,1.28L7.44,12l-1.41,1.41L11.62,19l2.69,-2.69 4.89,4.89 1.41,-1.41L3.65,2.81 2.24,4.22zM17.14,13.49L23.62,7 22.2,5.59l-6.48,6.48 1.42,1.42zM17.96,7l-1.41,-1.41 -3.65,3.66 1.41,1.41L17.96,7z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
</vector>

View File

@ -15,6 +15,14 @@
%3$s: background color
*/
@font-face {
font-family: Inter;
font-style: normal;
font-weight: normal;
font-display: swap;
src: url("fonts/Inter-Regular.woff2") format("woff2");
}
a:link, a:active, a:hover { color: %1$s }
* {
@ -31,6 +39,7 @@
margin-right: 3%%;
color: %2$s;
background-color: %3$s;
font-family: Inter !important;
}
figure, img, iframe, video {

View File

@ -10,6 +10,7 @@ import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.ItemState
import com.readrops.db.pojo.ItemWithFeed
import kotlinx.coroutines.flow.Flow
@Dao
abstract class NewItemDao : NewBaseDao<Item> {
@ -18,7 +19,7 @@ abstract class NewItemDao : NewBaseDao<Item> {
abstract fun selectAll(query: SupportSQLiteQuery): PagingSource<Int, ItemWithFeed>
@RawQuery(observedEntities = [Item::class, ItemState::class])
abstract suspend fun selectItemById(query: SupportSQLiteQuery): ItemWithFeed
abstract fun selectItemById(query: SupportSQLiteQuery): Flow<ItemWithFeed>
@Query("Update Item Set read = :read Where id = :itemId")
abstract suspend fun updateReadState(itemId: Int, read: Boolean)