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 package com.readrops.app.compose.item
import android.util.Base64 import android.content.Intent
import android.webkit.WebView import android.net.Uri
import android.webkit.WebViewClient import android.widget.RelativeLayout
import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.children
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel import cafe.adriel.voyager.koin.getScreenModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.readrops.api.utils.DateUtils import com.readrops.api.utils.DateUtils
import com.readrops.app.compose.R 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.AndroidScreen
import com.readrops.app.compose.util.components.CenteredProgressIndicator import com.readrops.app.compose.util.components.CenteredProgressIndicator
import com.readrops.app.compose.util.components.IconText import com.readrops.app.compose.util.components.IconText
@ -47,90 +58,136 @@ import org.koin.core.parameter.parametersOf
import kotlin.math.roundToInt import kotlin.math.roundToInt
class ItemScreen( class ItemScreen(
private val itemId: Int, private val itemId: Int
) : AndroidScreen() { ) : AndroidScreen() {
@Composable @Composable
override fun Content() { override fun Content() {
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current
val screenModel = val screenModel =
getScreenModel<ItemScreenModel>(parameters = { parametersOf(itemId) }) getScreenModel<ItemScreenModel>(parameters = { parametersOf(itemId) })
val scrollState = rememberScrollState()
val state by screenModel.state.collectAsStateWithLifecycle() val state by screenModel.state.collectAsStateWithLifecycle()
val primaryColor = MaterialTheme.colorScheme.primary val primaryColor = MaterialTheme.colorScheme.primary
val backgroundColor = MaterialTheme.colorScheme.background val backgroundColor = MaterialTheme.colorScheme.background
val onBackgroundColor = MaterialTheme.colorScheme.onBackground val onBackgroundColor = MaterialTheme.colorScheme.onBackground
Column( var isScrollable by remember { mutableStateOf(true) }
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colorScheme.background)
.verticalScroll(scrollState)
) {
if (state.itemWithFeed != null) {
val itemWithFeed = state.itemWithFeed!!
if (itemWithFeed.item.imageLink != null) { // https://developer.android.com/develop/ui/compose/touch-input/pointer-input/scroll#parent-compose-child-view
BackgroundTitle( val bottomBarHeight = 64.dp
itemWithFeed = itemWithFeed val bottomBarHeightPx = with(density) { bottomBarHeight.roundToPx().toFloat() }
) val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) }
} else {
val tintColor = if (itemWithFeed.bgColor != 0) {
Color(itemWithFeed.bgColor)
} else {
MaterialTheme.colorScheme.onBackground
}
SimpleTitle( val nestedScrollConnection = remember {
itemWithFeed = itemWithFeed, object : NestedScrollConnection {
titleColor = tintColor, override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
accentColor = tintColor, val delta = available.y
baseColor = MaterialTheme.colorScheme.onBackground, val newOffset = bottomBarOffsetHeightPx.floatValue + delta
bottomPadding = true 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( SimpleTitle(
factory = { context -> itemWithFeed = itemWithFeed,
WebView(context).apply { titleColor = tintColor,
settings.javaScriptEnabled = true accentColor = tintColor,
webViewClient = WebViewClient() 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 webView.loadText(
settings.displayZoomControls = false itemWithFeed = itemWithFeed,
settings.setSupportZoom(false) accentColor = accentColor,
backgroundColor = backgroundColor,
isVerticalScrollBarEnabled = false onBackgroundColor = onBackgroundColor
setBackgroundColor(backgroundColor.toArgb()) )
} }
}, )
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(
text = itemWithFeed.feedName, text = itemWithFeed.feedName,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = baseColor color = baseColor,
textAlign = TextAlign.Center
) )
ShortSpacer() 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 package com.readrops.app.compose.item
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.db.Database 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.pojo.ItemWithFeed
import com.readrops.db.queries.ItemSelectionQueryBuilder import com.readrops.db.queries.ItemSelectionQueryBuilder
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.parser.Parser 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( class ItemScreenModel(
private val database: Database, private val database: Database,
private val itemId: Int private val itemId: Int,
) : StateScreenModel<ItemState>(ItemState()) { 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 { init {
screenModelScope.launch(Dispatchers.IO) { screenModelScope.launch(dispatcher) {
mutableState.update { database.newAccountDao().selectCurrentAccount()
val query = ItemSelectionQueryBuilder.buildQuery(itemId, false) .collect { account ->
this@ItemScreenModel.account = account!!
repository = get { parametersOf(account) }
it.copy( val query = ItemSelectionQueryBuilder.buildQuery(
itemWithFeed = database.newItemDao().selectItemById(query) 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() } document.select("div,span").forEach { it.clearAttributes() }
return document.body().html() 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 @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 %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 } a:link, a:active, a:hover { color: %1$s }
* { * {
@ -31,6 +39,7 @@
margin-right: 3%%; margin-right: 3%%;
color: %2$s; color: %2$s;
background-color: %3$s; background-color: %3$s;
font-family: Inter !important;
} }
figure, img, iframe, video { 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.Item
import com.readrops.db.entities.ItemState import com.readrops.db.entities.ItemState
import com.readrops.db.pojo.ItemWithFeed import com.readrops.db.pojo.ItemWithFeed
import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class NewItemDao : NewBaseDao<Item> { abstract class NewItemDao : NewBaseDao<Item> {
@ -18,7 +19,7 @@ abstract class NewItemDao : NewBaseDao<Item> {
abstract fun selectAll(query: SupportSQLiteQuery): PagingSource<Int, ItemWithFeed> abstract fun selectAll(query: SupportSQLiteQuery): PagingSource<Int, ItemWithFeed>
@RawQuery(observedEntities = [Item::class, ItemState::class]) @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") @Query("Update Item Set read = :read Where id = :itemId")
abstract suspend fun updateReadState(itemId: Int, read: Boolean) abstract suspend fun updateReadState(itemId: Int, read: Boolean)