Refactor Material You design for FlowPage
This commit is contained in:
parent
d288177feb
commit
f4828ac01a
|
@ -1,27 +1,3 @@
|
|||
package me.ash.reader
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
|
||||
fun Int.positive() = if (this < 0) 0 else this
|
||||
fun Int.finitelyLarge(value: Int) = if (this > value) value else this
|
||||
fun Int.finitelySmall(value: Int) = if (this < value) value else this
|
||||
|
||||
fun Float.positive() = if (this < 0) 0f else this
|
||||
fun Float.finitelyLarge(value: Float) = if (this > value) value else this
|
||||
fun Float.finitelySmall(value: Float) = if (this < value) value else this
|
||||
|
||||
@Composable
|
||||
fun <T : Any> rememberMutableStateListOf(vararg elements: T): SnapshotStateList<T> {
|
||||
return rememberSaveable(
|
||||
saver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toMutableStateList() }
|
||||
)
|
||||
) {
|
||||
elements.toMutableList().toMutableStateList()
|
||||
}
|
||||
}
|
||||
fun Int.spacerDollar(str: Any): String = "$this$$str"
|
|
@ -8,29 +8,33 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||
|
||||
class Filter(
|
||||
var index: Int,
|
||||
var title: String,
|
||||
var name: String,
|
||||
var description: String,
|
||||
var important: Int,
|
||||
var icon: ImageVector,
|
||||
) {
|
||||
fun isStarred(): Boolean = this == Starred
|
||||
fun isUnread(): Boolean = this == Unread
|
||||
fun isAll(): Boolean = this == All
|
||||
|
||||
companion object {
|
||||
val Starred = Filter(
|
||||
index = 0,
|
||||
title = "Starred",
|
||||
name = "Starred",
|
||||
description = " Starred Items",
|
||||
important = 13,
|
||||
icon = Icons.Rounded.StarOutline,
|
||||
)
|
||||
val Unread = Filter(
|
||||
index = 1,
|
||||
title = "Unread",
|
||||
name = "Unread",
|
||||
description = " Unread Items",
|
||||
important = 666,
|
||||
icon = Icons.Outlined.FiberManualRecord,
|
||||
)
|
||||
val All = Filter(
|
||||
index = 2,
|
||||
title = "All",
|
||||
name = "All",
|
||||
description = " Unread Items",
|
||||
important = 666,
|
||||
icon = Icons.Rounded.Subject,
|
||||
|
|
|
@ -12,6 +12,15 @@ interface FeedDao {
|
|||
)
|
||||
suspend fun queryAll(accountId: Int): List<Feed>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM feed
|
||||
WHERE accountId = :accountId
|
||||
and url = :url
|
||||
"""
|
||||
)
|
||||
fun queryByLink(accountId: Int, url: String): List<Feed>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(feed: Feed): Long
|
||||
|
||||
|
|
|
@ -22,6 +22,14 @@ interface GroupDao {
|
|||
)
|
||||
fun queryAllGroup(accountId: Int): Flow<MutableList<Group>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM `group`
|
||||
WHERE accountId = :accountId
|
||||
"""
|
||||
)
|
||||
suspend fun queryAll(accountId: Int): List<Group>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(group: Group): Long
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ abstract class AbstractRssRepository constructor(
|
|||
isStarred: Boolean = false,
|
||||
isUnread: Boolean = false,
|
||||
): Flow<List<ImportantCount>> {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId)!!
|
||||
Log.i(
|
||||
"RLog",
|
||||
"pullImportant: accountId: ${accountId}, isStarred: ${isStarred}, isUnread: ${isUnread}"
|
||||
|
@ -126,6 +126,11 @@ abstract class AbstractRssRepository constructor(
|
|||
return articleDao.queryById(id)
|
||||
}
|
||||
|
||||
fun isExist(url: String): Boolean {
|
||||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId)!!
|
||||
return feedDao.queryByLink(accountId, url).isNotEmpty()
|
||||
}
|
||||
|
||||
fun peekWork(): String {
|
||||
return workManager.getWorkInfosByTag("sync").get().size.toString()
|
||||
}
|
||||
|
|
|
@ -5,14 +5,18 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.data.account.Account
|
||||
import me.ash.reader.data.account.AccountDao
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.data.group.GroupDao
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import me.ash.reader.spacerDollar
|
||||
import javax.inject.Inject
|
||||
|
||||
class AccountRepository @Inject constructor(
|
||||
@ApplicationContext
|
||||
private val context: Context,
|
||||
private val accountDao: AccountDao,
|
||||
private val groupDao: GroupDao,
|
||||
) {
|
||||
|
||||
suspend fun getCurrentAccount(): Account? {
|
||||
|
@ -30,6 +34,16 @@ class AccountRepository @Inject constructor(
|
|||
type = Account.Type.LOCAL,
|
||||
).apply {
|
||||
id = accountDao.insert(this).toInt()
|
||||
}.also {
|
||||
if (groupDao.queryAll(it.id!!).isEmpty()) {
|
||||
groupDao.insert(
|
||||
Group(
|
||||
id = it.id!!.spacerDollar("0"),
|
||||
name = "默认",
|
||||
accountId = it.id!!,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ import me.ash.reader.data.source.FeverApiDataSource
|
|||
import me.ash.reader.data.source.RssNetworkDataSource
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import me.ash.reader.spacerDollar
|
||||
import net.dankito.readability4j.extended.Readability4JExtended
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
@ -82,7 +83,7 @@ class FeverRssRepository @Inject constructor(
|
|||
feverGroupsBody.groups.forEach {
|
||||
groupDao.insert(
|
||||
Group(
|
||||
id = it.id.toString(),
|
||||
id = accountId.spacerDollar(it.id),
|
||||
name = it.title,
|
||||
accountId = accountId,
|
||||
)
|
||||
|
@ -99,7 +100,7 @@ class FeverRssRepository @Inject constructor(
|
|||
}
|
||||
val feeds = feverFeeds.map {
|
||||
Feed(
|
||||
id = it.id.toString(),
|
||||
id = accountId.spacerDollar(it.id),
|
||||
name = it.title,
|
||||
url = it.url,
|
||||
groupId = feverFeedsGroupsMap[it.id].toString(),
|
||||
|
@ -116,7 +117,7 @@ class FeverRssRepository @Inject constructor(
|
|||
.forEach {
|
||||
articles.add(
|
||||
Article(
|
||||
id = it.id,
|
||||
id = accountId.spacerDollar(it.id),
|
||||
date = Date(it.created_on_time * 1000),
|
||||
title = it.title,
|
||||
author = it.author,
|
||||
|
|
|
@ -10,6 +10,7 @@ import javax.inject.Inject
|
|||
class OpmlRepository @Inject constructor(
|
||||
private val groupDao: GroupDao,
|
||||
private val feedDao: FeedDao,
|
||||
private val rssRepository: RssRepository,
|
||||
private val opmlLocalDataSource: OpmlLocalDataSource
|
||||
) {
|
||||
suspend fun saveToDatabase(inputStream: InputStream) {
|
||||
|
@ -18,6 +19,9 @@ class OpmlRepository @Inject constructor(
|
|||
groupWithFeedList.forEach { groupWithFeed ->
|
||||
groupDao.insert(groupWithFeed.group)
|
||||
groupWithFeed.feeds.forEach { it.groupId = groupWithFeed.group.id }
|
||||
groupWithFeed.feeds.removeIf {
|
||||
rssRepository.get().isExist(it.url)
|
||||
}
|
||||
feedDao.insertList(groupWithFeed.feeds)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -11,6 +11,7 @@ import me.ash.reader.data.feed.FeedWithArticle
|
|||
import me.ash.reader.data.source.RssNetworkDataSource
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import me.ash.reader.spacerDollar
|
||||
import net.dankito.readability4j.Readability4J
|
||||
import net.dankito.readability4j.extended.Readability4JExtended
|
||||
import okhttp3.*
|
||||
|
@ -28,17 +29,17 @@ class RssHelper @Inject constructor(
|
|||
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
|
||||
val parseRss = rssNetworkDataSource.parseRss(feedLink)
|
||||
val feed = Feed(
|
||||
id = UUID.randomUUID().toString(),
|
||||
id = accountId.spacerDollar(UUID.randomUUID().toString()),
|
||||
name = parseRss.title!!,
|
||||
url = feedLink,
|
||||
groupId = UUID.randomUUID().toString(),
|
||||
groupId = "",
|
||||
accountId = accountId,
|
||||
)
|
||||
val articles = mutableListOf<Article>()
|
||||
parseRss.items.forEach {
|
||||
articles.add(
|
||||
Article(
|
||||
id = UUID.randomUUID().toString(),
|
||||
id = accountId.spacerDollar(UUID.randomUUID().toString()),
|
||||
accountId = accountId,
|
||||
feedId = feed.id,
|
||||
date = Date(it.publishDate.toString()),
|
||||
|
@ -101,7 +102,7 @@ class RssHelper @Inject constructor(
|
|||
Log.i("RLog", "request rss ${feed.name}: ${it.title}")
|
||||
a.add(
|
||||
Article(
|
||||
id = UUID.randomUUID().toString(),
|
||||
id = accountId.spacerDollar(UUID.randomUUID().toString()),
|
||||
accountId = accountId,
|
||||
feedId = feed.id,
|
||||
date = Date(it.publishDate.toString()),
|
||||
|
@ -126,6 +127,7 @@ class RssHelper @Inject constructor(
|
|||
feed: Feed,
|
||||
articleLink: String?,
|
||||
) {
|
||||
try {
|
||||
if (articleLink == null) return
|
||||
val execute = OkHttpClient()
|
||||
.newCall(Request.Builder().url(articleLink).build())
|
||||
|
@ -156,6 +158,9 @@ class RssHelper @Inject constructor(
|
|||
} else {
|
||||
// saveRssIcon(feedDao, feed, "")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RLog", "queryRssIcon: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveRssIcon(feedDao: FeedDao, feed: Feed, iconLink: String) {
|
||||
|
|
|
@ -19,8 +19,8 @@ import kotlinx.coroutines.launch
|
|||
import me.ash.reader.data.constant.Symbol
|
||||
import me.ash.reader.ui.extension.collectAsStateValue
|
||||
import me.ash.reader.ui.extension.findActivity
|
||||
import me.ash.reader.ui.page.home.article.ArticlePage
|
||||
import me.ash.reader.ui.page.home.feeds.FeedsPage
|
||||
import me.ash.reader.ui.page.home.flow.FlowPage
|
||||
import me.ash.reader.ui.page.home.read.ReadPage
|
||||
import me.ash.reader.ui.page.home.read.ReadViewAction
|
||||
import me.ash.reader.ui.page.home.read.ReadViewModel
|
||||
|
@ -98,35 +98,10 @@ fun HomePage(
|
|||
state = viewState.pagerState,
|
||||
composableList = listOf(
|
||||
{
|
||||
FeedsPage(
|
||||
navController = navController,
|
||||
)
|
||||
FeedsPage(navController = navController)
|
||||
},
|
||||
{
|
||||
ArticlePage(
|
||||
navController = navController,
|
||||
BackOnClick = {
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 0,
|
||||
)
|
||||
)
|
||||
},
|
||||
articleOnClick = {
|
||||
readViewModel.dispatch(ReadViewAction.ScrollToItem(0))
|
||||
readViewModel.dispatch(ReadViewAction.InitData(it))
|
||||
if (it.feed.isFullContent) readViewModel.dispatch(ReadViewAction.RenderFullContent)
|
||||
else readViewModel.dispatch(ReadViewAction.RenderDescriptionContent)
|
||||
readViewModel.dispatch(ReadViewAction.RenderDescriptionContent)
|
||||
viewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 2,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
FlowPage(navController = navController)
|
||||
},
|
||||
{
|
||||
ReadPage(
|
||||
|
|
|
@ -1,143 +0,0 @@
|
|||
package me.ash.reader.ui.page.home.article
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.R
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.ui.extension.paddingFixedHorizontal
|
||||
import me.ash.reader.ui.extension.roundClick
|
||||
|
||||
@Composable
|
||||
fun ArticleItem(
|
||||
modifier: Modifier = Modifier,
|
||||
articleWithFeed: ArticleWithFeed? = null,
|
||||
isStarredFilter: Boolean,
|
||||
index: Int,
|
||||
articleOnClick: (ArticleWithFeed) -> Unit,
|
||||
) {
|
||||
if (articleWithFeed == null) return
|
||||
Column(
|
||||
modifier = modifier
|
||||
.paddingFixedHorizontal(
|
||||
top = if (index == 0) 8.dp else 0.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
.roundClick {
|
||||
articleOnClick(articleWithFeed)
|
||||
}
|
||||
.alpha(
|
||||
if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
1f
|
||||
} else {
|
||||
0.7f
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = modifier.padding(10.dp)) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 32.dp),
|
||||
text = articleWithFeed.feed.name,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = articleWithFeed.article.date.toString(
|
||||
DateTimeExt.HH_MM
|
||||
),
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
Spacer(modifier = modifier.height(1.dp))
|
||||
Row {
|
||||
if (true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 3.dp)
|
||||
.size(24.dp)
|
||||
.border(
|
||||
2.dp,
|
||||
MaterialTheme.colorScheme.inverseOnSurface,
|
||||
RoundedCornerShape(4.dp)
|
||||
),
|
||||
) {
|
||||
if (articleWithFeed.feed.icon == null) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.default_folder),
|
||||
contentDescription = "icon",
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(2.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = BitmapPainter(
|
||||
BitmapFactory.decodeByteArray(
|
||||
articleWithFeed.feed.icon,
|
||||
0,
|
||||
articleWithFeed.feed.icon!!.size
|
||||
).asImageBitmap()
|
||||
),
|
||||
contentDescription = "icon",
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = articleWithFeed.article.title,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isStarredFilter || articleWithFeed.article.isUnread) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = modifier.height(1.dp))
|
||||
Text(
|
||||
text = articleWithFeed.article.shortDescription,
|
||||
fontSize = 18.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
package me.ash.reader.ui.page.home.article
|
||||
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.constant.Filter
|
||||
import me.ash.reader.ui.extension.collectAsStateValue
|
||||
import me.ash.reader.ui.page.home.HomeViewAction
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import me.ash.reader.ui.widget.AnimateLazyColumn
|
||||
import me.ash.reader.ui.widget.TopTitleBox
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@DelicateCoroutinesApi
|
||||
@Composable
|
||||
fun ArticlePage(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
homeViewModel: HomeViewModel = hiltViewModel(),
|
||||
viewModel: ArticleViewModel = hiltViewModel(),
|
||||
BackOnClick: () -> Unit,
|
||||
articleOnClick: (ArticleWithFeed) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val filterState = homeViewModel.filterState.collectAsStateValue()
|
||||
val pagingItems = viewState.pagingData?.collectAsLazyPagingItems()
|
||||
val refreshState = rememberSwipeRefreshState(isRefreshing = viewState.isRefreshing)
|
||||
val syncState = homeViewModel.syncState.collectAsStateValue()
|
||||
|
||||
LaunchedEffect(homeViewModel.filterState) {
|
||||
homeViewModel.filterState.collect { state ->
|
||||
Log.i("RLog", "LaunchedEffect filterState: ")
|
||||
viewModel.dispatch(
|
||||
ArticleViewAction.FetchData(
|
||||
groupId = state.group?.id,
|
||||
feedId = state.feed?.id,
|
||||
isStarred = state.filter.let { it != Filter.All && it == Filter.Starred },
|
||||
isUnread = state.filter.let { it != Filter.All && it == Filter.Unread },
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = refreshState,
|
||||
refreshTriggerDistance = 100.dp,
|
||||
onRefresh = {
|
||||
if (syncState.isSyncing) return@SwipeRefresh
|
||||
homeViewModel.dispatch(HomeViewAction.Sync())
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
TopTitleBox(
|
||||
title = when {
|
||||
filterState.group != null -> filterState.group.name
|
||||
filterState.feed != null -> filterState.feed.name
|
||||
else -> filterState.filter.title
|
||||
},
|
||||
description = if (syncState.isSyncing) {
|
||||
"Syncing (${syncState.syncedCount}/${syncState.feedCount}) : ${syncState.currentFeedName}"
|
||||
} else {
|
||||
"${viewState.filterImportant}${filterState.filter.description}"
|
||||
},
|
||||
listState = viewState.listState,
|
||||
startOffset = Offset(if (true) 52f else 20f, 72f),
|
||||
startHeight = 50f,
|
||||
startTitleFontSize = 24f,
|
||||
startDescriptionFontSize = 14f,
|
||||
) {
|
||||
viewModel.dispatch(ArticleViewAction.ScrollToItem(0))
|
||||
}
|
||||
Column {
|
||||
ArticlePageTopBar(
|
||||
backOnClick = BackOnClick,
|
||||
readAllOnClick = {
|
||||
viewModel.dispatch(ArticleViewAction.PeekSyncWork)
|
||||
Toast.makeText(context, viewState.syncWorkInfo, Toast.LENGTH_LONG)
|
||||
.show()
|
||||
},
|
||||
searchOnClick = {
|
||||
|
||||
},
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
AnimateLazyColumn(
|
||||
state = viewState.listState,
|
||||
reference = filterState.filter,
|
||||
) {
|
||||
if (pagingItems == null) return@AnimateLazyColumn
|
||||
var lastItemDay: String? = null
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(74.dp))
|
||||
}
|
||||
for (itemIndex in 0 until pagingItems.itemCount) {
|
||||
val currentItem = pagingItems.peek(itemIndex)
|
||||
val currentItemDay =
|
||||
currentItem?.article?.date?.toString(DateTimeExt.YYYY_MM_DD, true)
|
||||
?: "null"
|
||||
if (lastItemDay != currentItemDay) {
|
||||
if (itemIndex != 0) {
|
||||
item { Spacer(modifier = Modifier.height(40.dp)) }
|
||||
}
|
||||
stickyHeader {
|
||||
ArticleDateHeader(currentItemDay, true)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ArticleItem(
|
||||
modifier = modifier,
|
||||
articleWithFeed = pagingItems[itemIndex],
|
||||
isStarredFilter = filterState.filter == Filter.Starred,
|
||||
index = itemIndex,
|
||||
articleOnClick = articleOnClick,
|
||||
)
|
||||
}
|
||||
lastItemDay = currentItemDay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
package me.ash.reader.ui.page.home.article
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.rounded.DoneAll
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SmallTopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
||||
@Composable
|
||||
fun ArticlePageTopBar(
|
||||
backOnClick: () -> Unit = {},
|
||||
readAllOnClick: () -> Unit = {},
|
||||
searchOnClick: () -> Unit = {},
|
||||
) {
|
||||
val view = LocalView.current
|
||||
SmallTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
backOnClick()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowBackIosNew,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
readAllOnClick()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DoneAll,
|
||||
contentDescription = "Done All",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
searchOnClick()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -15,7 +15,7 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun Feed(
|
||||
fun FeedItem(
|
||||
modifier: Modifier = Modifier,
|
||||
name: String,
|
||||
important: Int,
|
|
@ -2,12 +2,11 @@ package me.ash.reader.ui.page.home.feeds
|
|||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
|
@ -19,15 +18,13 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.data.constant.Filter
|
||||
import me.ash.reader.data.constant.Symbol
|
||||
import me.ash.reader.ui.extension.collectAsStateValue
|
||||
import me.ash.reader.ui.page.home.FilterState
|
||||
import me.ash.reader.ui.page.home.HomeViewAction
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeDialog
|
||||
|
@ -47,6 +44,7 @@ fun FeedsPage(
|
|||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val filterState = homeViewModel.filterState.collectAsStateValue()
|
||||
val syncState = homeViewModel.syncState.collectAsStateValue()
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
|
@ -65,10 +63,7 @@ fun FeedsPage(
|
|||
LaunchedEffect(homeViewModel.filterState) {
|
||||
homeViewModel.filterState.collect { state ->
|
||||
viewModel.dispatch(
|
||||
FeedsViewAction.FetchData(
|
||||
isStarred = state.filter.let { it != Filter.All && it == Filter.Starred },
|
||||
isUnread = state.filter.let { it != Filter.All && it == Filter.Unread },
|
||||
)
|
||||
FeedsViewAction.FetchData(state)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -93,9 +88,7 @@ fun FeedsPage(
|
|||
homeViewModel.dispatch(HomeViewAction.Sync())
|
||||
}) {
|
||||
Icon(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = if (syncState.isSyncing) angle else 0f
|
||||
},
|
||||
modifier = Modifier.rotate(if (syncState.isSyncing) angle else 0f),
|
||||
imageVector = Icons.Rounded.Refresh,
|
||||
contentDescription = "Refresh",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
|
@ -119,9 +112,8 @@ fun FeedsPage(
|
|||
viewModel.dispatch(FeedsViewAction.AddFromFile(it))
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
LazyColumn {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(
|
||||
start = 24.dp,
|
||||
|
@ -133,8 +125,10 @@ fun FeedsPage(
|
|||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Banner(
|
||||
title = viewState.filter.title,
|
||||
title = viewState.filter.name,
|
||||
desc = "${viewState.filter.important}${viewState.filter.description}",
|
||||
icon = viewState.filter.icon,
|
||||
action = {
|
||||
|
@ -144,24 +138,41 @@ fun FeedsPage(
|
|||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
) {
|
||||
homeViewModel.dispatch(
|
||||
HomeViewAction.ChangeFilter(
|
||||
filterState.copy(
|
||||
group = null,
|
||||
feed = null
|
||||
)
|
||||
)
|
||||
)
|
||||
homeViewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 1,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Subtitle(
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
modifier = Modifier.padding(start = 28.dp),
|
||||
text = "Feeds"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Column {
|
||||
viewState.groupWithFeedList.forEachIndexed { index, groupWithFeed ->
|
||||
Group(
|
||||
}
|
||||
itemsIndexed(viewState.groupWithFeedList) { index, groupWithFeed ->
|
||||
GroupItem(
|
||||
text = groupWithFeed.group.name,
|
||||
feeds = groupWithFeed.feeds,
|
||||
groupOnClick = {
|
||||
homeViewModel.dispatch(
|
||||
HomeViewAction.ChangeFilter(
|
||||
FilterState(
|
||||
filterState.copy(
|
||||
group = groupWithFeed.group,
|
||||
feed = null,
|
||||
feed = null
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -175,9 +186,9 @@ fun FeedsPage(
|
|||
feedOnClick = { feed ->
|
||||
homeViewModel.dispatch(
|
||||
HomeViewAction.ChangeFilter(
|
||||
FilterState(
|
||||
filterState.copy(
|
||||
group = null,
|
||||
feed = feed,
|
||||
feed = feed
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -195,7 +206,6 @@ fun FeedsPage(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import me.ash.reader.data.group.GroupWithFeed
|
|||
import me.ash.reader.data.repository.AccountRepository
|
||||
import me.ash.reader.data.repository.OpmlRepository
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.ui.page.home.FilterState
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -29,7 +30,7 @@ class FeedsViewModel @Inject constructor(
|
|||
fun dispatch(action: FeedsViewAction) {
|
||||
when (action) {
|
||||
is FeedsViewAction.FetchAccount -> fetchAccount(action.callback)
|
||||
is FeedsViewAction.FetchData -> fetchData(action.isStarred, action.isUnread)
|
||||
is FeedsViewAction.FetchData -> fetchData(action.filterState)
|
||||
is FeedsViewAction.AddFromFile -> addFromFile(action.inputStream)
|
||||
is FeedsViewAction.ScrollToItem -> scrollToItem(action.index)
|
||||
}
|
||||
|
@ -53,9 +54,12 @@ class FeedsViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun fetchData(isStarred: Boolean, isUnread: Boolean) {
|
||||
private fun fetchData(filterState: FilterState) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
pullFeeds(isStarred, isUnread)
|
||||
pullFeeds(
|
||||
isStarred = filterState.filter.isStarred(),
|
||||
isUnread = filterState.filter.isUnread(),
|
||||
)
|
||||
_viewState
|
||||
}
|
||||
}
|
||||
|
@ -135,8 +139,7 @@ data class FeedsViewState(
|
|||
|
||||
sealed class FeedsViewAction {
|
||||
data class FetchData(
|
||||
val isStarred: Boolean,
|
||||
val isUnread: Boolean,
|
||||
val filterState: FilterState,
|
||||
) : FeedsViewAction()
|
||||
|
||||
data class FetchAccount(
|
||||
|
|
|
@ -20,7 +20,7 @@ import androidx.compose.ui.unit.dp
|
|||
import me.ash.reader.data.feed.Feed
|
||||
|
||||
@Composable
|
||||
fun Group(
|
||||
fun GroupItem(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
feeds: List<Feed>,
|
||||
|
@ -37,7 +37,7 @@ fun Group(
|
|||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.14f))
|
||||
.clickable { groupOnClick() }
|
||||
.padding(top = 22.dp, bottom = if (expanded) 14.dp else 22.dp)
|
||||
.padding(vertical = 22.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
|
@ -76,9 +76,11 @@ fun Group(
|
|||
exit = fadeOut() + shrinkVertically(),
|
||||
) {
|
||||
Column {
|
||||
if (feeds.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
feeds.forEach { feed ->
|
||||
Feed(
|
||||
FeedItem(
|
||||
modifier = Modifier.padding(horizontal = 20.dp),
|
||||
name = feed.name,
|
||||
important = feed.important ?: 0,
|
|
@ -2,9 +2,9 @@ package me.ash.reader.ui.page.home.feeds.subscribe
|
|||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Article
|
||||
import androidx.compose.material.icons.outlined.Notifications
|
||||
|
@ -13,14 +13,13 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.google.accompanist.flowlayout.MainAxisAlignment
|
||||
import me.ash.reader.data.group.Group
|
||||
import me.ash.reader.ui.widget.SelectionChip
|
||||
import me.ash.reader.ui.widget.SelectionEditorChip
|
||||
import me.ash.reader.ui.widget.Subtitle
|
||||
|
||||
@Composable
|
||||
fun ResultViewPage(
|
||||
|
@ -34,7 +33,9 @@ fun ResultViewPage(
|
|||
groupOnClick: (groupId: String) -> Unit = {},
|
||||
onKeyboardAction: () -> Unit = {},
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Link(
|
||||
text = link
|
||||
)
|
||||
|
@ -82,11 +83,7 @@ private fun Preset(
|
|||
notificationPresetOnClick: () -> Unit = {},
|
||||
fullContentParsePresetOnClick: () -> Unit = {},
|
||||
) {
|
||||
Text(
|
||||
text = "预设",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Subtitle(text = "预设")
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
FlowRow(
|
||||
mainAxisAlignment = MainAxisAlignment.Start,
|
||||
|
@ -94,38 +91,36 @@ private fun Preset(
|
|||
mainAxisSpacing = 10.dp,
|
||||
) {
|
||||
SelectionChip(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
content = "接收通知",
|
||||
selected = selectedNotificationPreset,
|
||||
selectedIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Notifications,
|
||||
contentDescription = "Check",
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(18.dp),
|
||||
)
|
||||
},
|
||||
onClick = notificationPresetOnClick,
|
||||
) {
|
||||
Text(
|
||||
text = "接收通知",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
notificationPresetOnClick()
|
||||
}
|
||||
SelectionChip(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
content = "全文解析",
|
||||
selected = selectedFullContentParsePreset,
|
||||
selectedIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Article,
|
||||
contentDescription = "Check",
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(18.dp),
|
||||
)
|
||||
},
|
||||
onClick = fullContentParsePresetOnClick,
|
||||
) {
|
||||
Text(
|
||||
text = "全文解析",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
fullContentParsePresetOnClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -137,11 +132,7 @@ private fun AddToGroup(
|
|||
groupOnClick: (groupId: String) -> Unit = {},
|
||||
onKeyboardAction: () -> Unit = {},
|
||||
) {
|
||||
Text(
|
||||
text = "添加到组",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Subtitle(text = "添加到组")
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
FlowRow(
|
||||
mainAxisAlignment = MainAxisAlignment.Start,
|
||||
|
@ -151,37 +142,20 @@ private fun AddToGroup(
|
|||
groups.forEach {
|
||||
SelectionChip(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
content = it.name,
|
||||
selected = it.id == selectedGroupId,
|
||||
onClick = { groupOnClick(it.id) },
|
||||
) {
|
||||
Text(
|
||||
text = it.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
groupOnClick(it.id)
|
||||
}
|
||||
}
|
||||
|
||||
SelectionChip(
|
||||
SelectionEditorChip(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
content = "新建分组",
|
||||
selected = false,
|
||||
onClick = { /*TODO*/ },
|
||||
onKeyboardAction = onKeyboardAction,
|
||||
) {
|
||||
BasicTextField(
|
||||
modifier = Modifier.width(56.dp),
|
||||
value = "新建分组",
|
||||
onValueChange = {},
|
||||
textStyle = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
),
|
||||
singleLine = true,
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
onKeyboardAction()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,6 +13,10 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import me.ash.reader.DataStoreKeys
|
||||
import me.ash.reader.dataStore
|
||||
import me.ash.reader.get
|
||||
import me.ash.reader.spacerDollar
|
||||
import me.ash.reader.ui.extension.collectAsStateValue
|
||||
import me.ash.reader.ui.widget.Dialog
|
||||
import java.io.InputStream
|
||||
|
@ -38,6 +42,10 @@ fun SubscribeDialog(
|
|||
|
||||
LaunchedEffect(viewState.visible) {
|
||||
if (viewState.visible) {
|
||||
val defaultGroupId = context.dataStore
|
||||
.get(DataStoreKeys.CurrentAccountId)!!
|
||||
.spacerDollar("0")
|
||||
viewModel.dispatch(SubscribeViewAction.SelectedGroup(defaultGroupId))
|
||||
viewModel.dispatch(SubscribeViewAction.Init)
|
||||
} else {
|
||||
viewModel.dispatch(SubscribeViewAction.Reset)
|
||||
|
|
|
@ -127,6 +127,14 @@ class SubscribeViewModel @Inject constructor(
|
|||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (rssRepository.get().isExist(_viewState.value.inputContent)) {
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
errorMessage = "已订阅",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val feedWithArticle = rssHelper.searchFeed(_viewState.value.inputContent)
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
|
||||
@Composable
|
||||
fun ArticleItem(
|
||||
modifier: Modifier = Modifier,
|
||||
articleWithFeed: ArticleWithFeed,
|
||||
onClick: (ArticleWithFeed) -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onClick(articleWithFeed) }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.alpha(if (articleWithFeed.article.isUnread) 1f else 0.5f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 30.dp),
|
||||
text = articleWithFeed.feed.name,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Text(
|
||||
text = articleWithFeed.article.date.toString(DateTimeExt.HH_MM),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
|
||||
) {}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = articleWithFeed.article.title,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = articleWithFeed.article.shortDescription,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import me.ash.reader.DateTimeExt
|
||||
import me.ash.reader.DateTimeExt.toString
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.ui.page.home.HomeViewAction
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import me.ash.reader.ui.page.home.read.ReadViewAction
|
||||
import me.ash.reader.ui.page.home.read.ReadViewModel
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun LazyListScope.generateArticleList(
|
||||
pagingItems: LazyPagingItems<ArticleWithFeed>?,
|
||||
readViewModel: ReadViewModel,
|
||||
homeViewModel: HomeViewModel,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
if (pagingItems == null) return
|
||||
var lastItemDay: String? = null
|
||||
for (itemIndex in 0 until pagingItems.itemCount) {
|
||||
val currentItem = pagingItems.peek(itemIndex) ?: continue
|
||||
val currentItemDay = currentItem.article.date
|
||||
.toString(DateTimeExt.YYYY_MM_DD, true)
|
||||
if (lastItemDay != currentItemDay) {
|
||||
if (itemIndex != 0) {
|
||||
item { Spacer(modifier = Modifier.height(40.dp)) }
|
||||
}
|
||||
stickyHeader {
|
||||
StickyHeader(currentItemDay)
|
||||
}
|
||||
}
|
||||
item {
|
||||
ArticleItem(
|
||||
articleWithFeed = currentItem,
|
||||
) {
|
||||
readViewModel.dispatch(ReadViewAction.ScrollToItem(0))
|
||||
readViewModel.dispatch(ReadViewAction.InitData(it))
|
||||
if (it.feed.isFullContent) readViewModel.dispatch(ReadViewAction.RenderFullContent)
|
||||
else readViewModel.dispatch(ReadViewAction.RenderDescriptionContent)
|
||||
readViewModel.dispatch(ReadViewAction.RenderDescriptionContent)
|
||||
homeViewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 2,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lastItemDay = currentItemDay
|
||||
}
|
||||
}
|
121
app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt
Normal file
121
app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt
Normal file
|
@ -0,0 +1,121 @@
|
|||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.DoneAll
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import me.ash.reader.ui.extension.collectAsStateValue
|
||||
import me.ash.reader.ui.page.home.HomeViewAction
|
||||
import me.ash.reader.ui.page.home.HomeViewModel
|
||||
import me.ash.reader.ui.page.home.read.ReadViewModel
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
ExperimentalFoundationApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun FlowPage(
|
||||
modifier: Modifier = Modifier,
|
||||
navController: NavHostController,
|
||||
viewModel: FlowViewModel = hiltViewModel(),
|
||||
homeViewModel: HomeViewModel = hiltViewModel(),
|
||||
readViewModel: ReadViewModel = hiltViewModel(),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val viewState = viewModel.viewState.collectAsStateValue()
|
||||
val filterState = homeViewModel.filterState.collectAsStateValue()
|
||||
val pagingItems = viewState.pagingData?.collectAsLazyPagingItems()
|
||||
|
||||
LaunchedEffect(homeViewModel.filterState) {
|
||||
homeViewModel.filterState.collect { state ->
|
||||
viewModel.dispatch(
|
||||
FlowViewAction.FetchData(state)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
homeViewModel.dispatch(
|
||||
HomeViewAction.ScrollToPage(
|
||||
scope = scope,
|
||||
targetPage = 0,
|
||||
)
|
||||
)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DoneAll,
|
||||
contentDescription = "Read All",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
content = {
|
||||
LazyColumn(
|
||||
state = viewState.listState,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = if (true) 54.dp else 24.dp,
|
||||
top = 48.dp,
|
||||
end = 24.dp,
|
||||
bottom = 24.dp
|
||||
),
|
||||
text = when {
|
||||
filterState.group != null -> filterState.group.name
|
||||
filterState.feed != null -> filterState.feed.name
|
||||
else -> filterState.filter.name
|
||||
},
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
generateArticleList(pagingItems, readViewModel, homeViewModel, scope)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package me.ash.reader.ui.page.home.article
|
||||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
@ -13,26 +13,22 @@ import kotlinx.coroutines.flow.*
|
|||
import kotlinx.coroutines.launch
|
||||
import me.ash.reader.data.article.ArticleWithFeed
|
||||
import me.ash.reader.data.repository.RssRepository
|
||||
import me.ash.reader.ui.page.home.FilterState
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ArticleViewModel @Inject constructor(
|
||||
class FlowViewModel @Inject constructor(
|
||||
private val rssRepository: RssRepository,
|
||||
) : ViewModel() {
|
||||
private val _viewState = MutableStateFlow(ArticleViewState())
|
||||
val viewState: StateFlow<ArticleViewState> = _viewState.asStateFlow()
|
||||
|
||||
fun dispatch(action: ArticleViewAction) {
|
||||
fun dispatch(action: FlowViewAction) {
|
||||
when (action) {
|
||||
is ArticleViewAction.FetchData -> fetchData(
|
||||
groupId = action.groupId,
|
||||
feedId = action.feedId,
|
||||
isStarred = action.isStarred,
|
||||
isUnread = action.isUnread,
|
||||
)
|
||||
is ArticleViewAction.ChangeRefreshing -> changeRefreshing(action.isRefreshing)
|
||||
is ArticleViewAction.ScrollToItem -> scrollToItem(action.index)
|
||||
is ArticleViewAction.PeekSyncWork -> peekSyncWork()
|
||||
is FlowViewAction.FetchData -> fetchData(action.filterState)
|
||||
is FlowViewAction.ChangeRefreshing -> changeRefreshing(action.isRefreshing)
|
||||
is FlowViewAction.ScrollToItem -> scrollToItem(action.index)
|
||||
is FlowViewAction.PeekSyncWork -> peekSyncWork()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,14 +40,9 @@ class ArticleViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun fetchData(
|
||||
groupId: String? = null,
|
||||
feedId: String? = null,
|
||||
isStarred: Boolean,
|
||||
isUnread: Boolean,
|
||||
) {
|
||||
private fun fetchData(filterState: FilterState) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
rssRepository.get().pullImportant(isStarred, true)
|
||||
rssRepository.get().pullImportant(filterState.filter.isStarred(), true)
|
||||
.collect { importantList ->
|
||||
_viewState.update {
|
||||
it.copy(
|
||||
|
@ -64,10 +55,10 @@ class ArticleViewModel @Inject constructor(
|
|||
it.copy(
|
||||
pagingData = Pager(PagingConfig(pageSize = 10)) {
|
||||
rssRepository.get().pullArticles(
|
||||
groupId = groupId,
|
||||
feedId = feedId,
|
||||
isStarred = isStarred,
|
||||
isUnread = isUnread,
|
||||
groupId = filterState.group?.id,
|
||||
feedId = filterState.feed?.id,
|
||||
isStarred = filterState.filter.isStarred(),
|
||||
isUnread = filterState.filter.isUnread(),
|
||||
)
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
)
|
||||
|
@ -95,21 +86,18 @@ data class ArticleViewState(
|
|||
val syncWorkInfo: String = "",
|
||||
)
|
||||
|
||||
sealed class ArticleViewAction {
|
||||
sealed class FlowViewAction {
|
||||
data class FetchData(
|
||||
val groupId: String? = null,
|
||||
val feedId: String? = null,
|
||||
val isStarred: Boolean,
|
||||
val isUnread: Boolean,
|
||||
) : ArticleViewAction()
|
||||
val filterState: FilterState,
|
||||
) : FlowViewAction()
|
||||
|
||||
data class ChangeRefreshing(
|
||||
val isRefreshing: Boolean
|
||||
) : ArticleViewAction()
|
||||
) : FlowViewAction()
|
||||
|
||||
data class ScrollToItem(
|
||||
val index: Int
|
||||
) : ArticleViewAction()
|
||||
) : FlowViewAction()
|
||||
|
||||
object PeekSyncWork : ArticleViewAction()
|
||||
object PeekSyncWork : FlowViewAction()
|
||||
}
|
|
@ -1,37 +1,29 @@
|
|||
package me.ash.reader.ui.page.home.article
|
||||
package me.ash.reader.ui.page.home.flow
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun ArticleDateHeader(
|
||||
date: String,
|
||||
isDisplayIcon: Boolean
|
||||
) {
|
||||
fun StickyHeader(currentItemDay: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(28.dp)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
modifier = Modifier.padding(start = (if (isDisplayIcon) 52 else 20).dp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(start = if (true) 54.dp else 24.dp),
|
||||
text = currentItemDay,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -22,8 +22,8 @@ fun Banner(
|
|||
title: String,
|
||||
desc: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit = {},
|
||||
action: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
package me.ash.reader.ui.widget
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.material.ChipDefaults
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FilterChip
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -20,20 +24,22 @@ import androidx.compose.ui.unit.dp
|
|||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun SelectionChip(
|
||||
content: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shape: Shape = CircleShape,
|
||||
selectedIcon: @Composable () -> Unit = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = "Check",
|
||||
modifier = Modifier.size(20.dp)
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(18.dp)
|
||||
)
|
||||
},
|
||||
content: @Composable RowScope.() -> Unit
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FilterChip(
|
||||
modifier = modifier,
|
||||
|
@ -54,6 +60,83 @@ fun SelectionChip(
|
|||
selectedIcon = selectedIcon,
|
||||
shape = shape,
|
||||
onClick = onClick,
|
||||
content = content,
|
||||
content = {
|
||||
Text(
|
||||
modifier = modifier.padding(
|
||||
start = if (selected) 0.dp else 8.dp,
|
||||
top = 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = 8.dp
|
||||
),
|
||||
text = content,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun SelectionEditorChip(
|
||||
content: String,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shape: Shape = CircleShape,
|
||||
selectedIcon: @Composable () -> Unit = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = "Check",
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(16.dp)
|
||||
)
|
||||
},
|
||||
onKeyboardAction: () -> Unit = {},
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FilterChip(
|
||||
modifier = modifier,
|
||||
colors = ChipDefaults.filterChipColors(
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
disabledBackgroundColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
|
||||
disabledContentColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
|
||||
disabledLeadingIconColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
|
||||
selectedBackgroundColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
selectedLeadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
enabled = enabled,
|
||||
selected = selected,
|
||||
selectedIcon = selectedIcon,
|
||||
shape = shape,
|
||||
onClick = onClick,
|
||||
content = {
|
||||
BasicTextField(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = if (selected) 0.dp else 8.dp,
|
||||
top = 8.dp,
|
||||
end = 8.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
.width(56.dp),
|
||||
value = content,
|
||||
onValueChange = {},
|
||||
textStyle = MaterialTheme.typography.titleSmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
singleLine = true,
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
onKeyboardAction()
|
||||
}
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
|
@ -11,15 +11,15 @@ import androidx.compose.ui.unit.dp
|
|||
|
||||
@Composable
|
||||
fun Subtitle(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
color: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp, 8.dp, 16.dp, 8.dp),
|
||||
.padding(vertical = 8.dp),
|
||||
color = color,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue
Block a user