Refactor Material You design for FlowPage

This commit is contained in:
Ash 2022-03-19 21:10:22 +08:00
parent d288177feb
commit f4828ac01a
28 changed files with 627 additions and 642 deletions

View File

@ -1,27 +1,3 @@
package me.ash.reader package me.ash.reader
import androidx.compose.runtime.Composable fun Int.spacerDollar(str: Any): String = "$this$$str"
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()
}
}

View File

@ -8,29 +8,33 @@ import androidx.compose.ui.graphics.vector.ImageVector
class Filter( class Filter(
var index: Int, var index: Int,
var title: String, var name: String,
var description: String, var description: String,
var important: Int, var important: Int,
var icon: ImageVector, var icon: ImageVector,
) { ) {
fun isStarred(): Boolean = this == Starred
fun isUnread(): Boolean = this == Unread
fun isAll(): Boolean = this == All
companion object { companion object {
val Starred = Filter( val Starred = Filter(
index = 0, index = 0,
title = "Starred", name = "Starred",
description = " Starred Items", description = " Starred Items",
important = 13, important = 13,
icon = Icons.Rounded.StarOutline, icon = Icons.Rounded.StarOutline,
) )
val Unread = Filter( val Unread = Filter(
index = 1, index = 1,
title = "Unread", name = "Unread",
description = " Unread Items", description = " Unread Items",
important = 666, important = 666,
icon = Icons.Outlined.FiberManualRecord, icon = Icons.Outlined.FiberManualRecord,
) )
val All = Filter( val All = Filter(
index = 2, index = 2,
title = "All", name = "All",
description = " Unread Items", description = " Unread Items",
important = 666, important = 666,
icon = Icons.Rounded.Subject, icon = Icons.Rounded.Subject,

View File

@ -12,6 +12,15 @@ interface FeedDao {
) )
suspend fun queryAll(accountId: Int): List<Feed> 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 @Insert
suspend fun insert(feed: Feed): Long suspend fun insert(feed: Feed): Long

View File

@ -22,6 +22,14 @@ interface GroupDao {
) )
fun queryAllGroup(accountId: Int): Flow<MutableList<Group>> fun queryAllGroup(accountId: Int): Flow<MutableList<Group>>
@Query(
"""
SELECT * FROM `group`
WHERE accountId = :accountId
"""
)
suspend fun queryAll(accountId: Int): List<Group>
@Insert @Insert
suspend fun insert(group: Group): Long suspend fun insert(group: Group): Long

View File

@ -108,7 +108,7 @@ abstract class AbstractRssRepository constructor(
isStarred: Boolean = false, isStarred: Boolean = false,
isUnread: Boolean = false, isUnread: Boolean = false,
): Flow<List<ImportantCount>> { ): Flow<List<ImportantCount>> {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0 val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId)!!
Log.i( Log.i(
"RLog", "RLog",
"pullImportant: accountId: ${accountId}, isStarred: ${isStarred}, isUnread: ${isUnread}" "pullImportant: accountId: ${accountId}, isStarred: ${isStarred}, isUnread: ${isUnread}"
@ -126,6 +126,11 @@ abstract class AbstractRssRepository constructor(
return articleDao.queryById(id) 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 { fun peekWork(): String {
return workManager.getWorkInfosByTag("sync").get().size.toString() return workManager.getWorkInfosByTag("sync").get().size.toString()
} }

View File

@ -5,14 +5,18 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import me.ash.reader.DataStoreKeys import me.ash.reader.DataStoreKeys
import me.ash.reader.data.account.Account import me.ash.reader.data.account.Account
import me.ash.reader.data.account.AccountDao 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.dataStore
import me.ash.reader.get import me.ash.reader.get
import me.ash.reader.spacerDollar
import javax.inject.Inject import javax.inject.Inject
class AccountRepository @Inject constructor( class AccountRepository @Inject constructor(
@ApplicationContext @ApplicationContext
private val context: Context, private val context: Context,
private val accountDao: AccountDao, private val accountDao: AccountDao,
private val groupDao: GroupDao,
) { ) {
suspend fun getCurrentAccount(): Account? { suspend fun getCurrentAccount(): Account? {
@ -30,6 +34,16 @@ class AccountRepository @Inject constructor(
type = Account.Type.LOCAL, type = Account.Type.LOCAL,
).apply { ).apply {
id = accountDao.insert(this).toInt() id = accountDao.insert(this).toInt()
}.also {
if (groupDao.queryAll(it.id!!).isEmpty()) {
groupDao.insert(
Group(
id = it.id!!.spacerDollar("0"),
name = "默认",
accountId = it.id!!,
)
)
}
} }
} }
} }

View File

@ -20,6 +20,7 @@ import me.ash.reader.data.source.FeverApiDataSource
import me.ash.reader.data.source.RssNetworkDataSource import me.ash.reader.data.source.RssNetworkDataSource
import me.ash.reader.dataStore import me.ash.reader.dataStore
import me.ash.reader.get import me.ash.reader.get
import me.ash.reader.spacerDollar
import net.dankito.readability4j.extended.Readability4JExtended import net.dankito.readability4j.extended.Readability4JExtended
import java.util.* import java.util.*
import javax.inject.Inject import javax.inject.Inject
@ -82,7 +83,7 @@ class FeverRssRepository @Inject constructor(
feverGroupsBody.groups.forEach { feverGroupsBody.groups.forEach {
groupDao.insert( groupDao.insert(
Group( Group(
id = it.id.toString(), id = accountId.spacerDollar(it.id),
name = it.title, name = it.title,
accountId = accountId, accountId = accountId,
) )
@ -99,7 +100,7 @@ class FeverRssRepository @Inject constructor(
} }
val feeds = feverFeeds.map { val feeds = feverFeeds.map {
Feed( Feed(
id = it.id.toString(), id = accountId.spacerDollar(it.id),
name = it.title, name = it.title,
url = it.url, url = it.url,
groupId = feverFeedsGroupsMap[it.id].toString(), groupId = feverFeedsGroupsMap[it.id].toString(),
@ -116,7 +117,7 @@ class FeverRssRepository @Inject constructor(
.forEach { .forEach {
articles.add( articles.add(
Article( Article(
id = it.id, id = accountId.spacerDollar(it.id),
date = Date(it.created_on_time * 1000), date = Date(it.created_on_time * 1000),
title = it.title, title = it.title,
author = it.author, author = it.author,

View File

@ -10,6 +10,7 @@ import javax.inject.Inject
class OpmlRepository @Inject constructor( class OpmlRepository @Inject constructor(
private val groupDao: GroupDao, private val groupDao: GroupDao,
private val feedDao: FeedDao, private val feedDao: FeedDao,
private val rssRepository: RssRepository,
private val opmlLocalDataSource: OpmlLocalDataSource private val opmlLocalDataSource: OpmlLocalDataSource
) { ) {
suspend fun saveToDatabase(inputStream: InputStream) { suspend fun saveToDatabase(inputStream: InputStream) {
@ -18,6 +19,9 @@ class OpmlRepository @Inject constructor(
groupWithFeedList.forEach { groupWithFeed -> groupWithFeedList.forEach { groupWithFeed ->
groupDao.insert(groupWithFeed.group) groupDao.insert(groupWithFeed.group)
groupWithFeed.feeds.forEach { it.groupId = groupWithFeed.group.id } groupWithFeed.feeds.forEach { it.groupId = groupWithFeed.group.id }
groupWithFeed.feeds.removeIf {
rssRepository.get().isExist(it.url)
}
feedDao.insertList(groupWithFeed.feeds) feedDao.insertList(groupWithFeed.feeds)
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -11,6 +11,7 @@ import me.ash.reader.data.feed.FeedWithArticle
import me.ash.reader.data.source.RssNetworkDataSource import me.ash.reader.data.source.RssNetworkDataSource
import me.ash.reader.dataStore import me.ash.reader.dataStore
import me.ash.reader.get import me.ash.reader.get
import me.ash.reader.spacerDollar
import net.dankito.readability4j.Readability4J import net.dankito.readability4j.Readability4J
import net.dankito.readability4j.extended.Readability4JExtended import net.dankito.readability4j.extended.Readability4JExtended
import okhttp3.* import okhttp3.*
@ -28,17 +29,17 @@ class RssHelper @Inject constructor(
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0 val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
val parseRss = rssNetworkDataSource.parseRss(feedLink) val parseRss = rssNetworkDataSource.parseRss(feedLink)
val feed = Feed( val feed = Feed(
id = UUID.randomUUID().toString(), id = accountId.spacerDollar(UUID.randomUUID().toString()),
name = parseRss.title!!, name = parseRss.title!!,
url = feedLink, url = feedLink,
groupId = UUID.randomUUID().toString(), groupId = "",
accountId = accountId, accountId = accountId,
) )
val articles = mutableListOf<Article>() val articles = mutableListOf<Article>()
parseRss.items.forEach { parseRss.items.forEach {
articles.add( articles.add(
Article( Article(
id = UUID.randomUUID().toString(), id = accountId.spacerDollar(UUID.randomUUID().toString()),
accountId = accountId, accountId = accountId,
feedId = feed.id, feedId = feed.id,
date = Date(it.publishDate.toString()), date = Date(it.publishDate.toString()),
@ -101,7 +102,7 @@ class RssHelper @Inject constructor(
Log.i("RLog", "request rss ${feed.name}: ${it.title}") Log.i("RLog", "request rss ${feed.name}: ${it.title}")
a.add( a.add(
Article( Article(
id = UUID.randomUUID().toString(), id = accountId.spacerDollar(UUID.randomUUID().toString()),
accountId = accountId, accountId = accountId,
feedId = feed.id, feedId = feed.id,
date = Date(it.publishDate.toString()), date = Date(it.publishDate.toString()),
@ -126,35 +127,39 @@ class RssHelper @Inject constructor(
feed: Feed, feed: Feed,
articleLink: String?, articleLink: String?,
) { ) {
if (articleLink == null) return try {
val execute = OkHttpClient() if (articleLink == null) return
.newCall(Request.Builder().url(articleLink).build()) val execute = OkHttpClient()
.execute() .newCall(Request.Builder().url(articleLink).build())
val content = execute.body?.string() .execute()
val regex = val content = execute.body?.string()
Regex("""<link(.+?)rel="shortcut icon"(.+?)href="(.+?)"""") val regex =
if (content != null) { Regex("""<link(.+?)rel="shortcut icon"(.+?)href="(.+?)"""")
var iconLink = regex if (content != null) {
.find(content) var iconLink = regex
?.groups?.get(3) .find(content)
?.value ?.groups?.get(3)
Log.i("rlog", "queryRssIcon: $iconLink") ?.value
if (iconLink != null) { Log.i("rlog", "queryRssIcon: $iconLink")
if (iconLink.startsWith("//")) { if (iconLink != null) {
iconLink = "http:$iconLink" if (iconLink.startsWith("//")) {
} iconLink = "http:$iconLink"
if (iconLink.startsWith("/")) { }
val domainRegex = if (iconLink.startsWith("/")) {
Regex("""http(s)?://(([\w-]+\.)+\w+(:\d{1,5})?)""") val domainRegex =
iconLink = Regex("""http(s)?://(([\w-]+\.)+\w+(:\d{1,5})?)""")
"http://${domainRegex.find(articleLink)?.groups?.get(2)?.value}$iconLink" iconLink =
} "http://${domainRegex.find(articleLink)?.groups?.get(2)?.value}$iconLink"
saveRssIcon(feedDao, feed, iconLink) }
} else { saveRssIcon(feedDao, feed, iconLink)
} else {
// saveRssIcon(feedDao, feed, "") // saveRssIcon(feedDao, feed, "")
} }
} else { } else {
// saveRssIcon(feedDao, feed, "") // saveRssIcon(feedDao, feed, "")
}
} catch (e: Exception) {
Log.e("RLog", "queryRssIcon: ${e.message}")
} }
} }

View File

@ -19,8 +19,8 @@ import kotlinx.coroutines.launch
import me.ash.reader.data.constant.Symbol import me.ash.reader.data.constant.Symbol
import me.ash.reader.ui.extension.collectAsStateValue import me.ash.reader.ui.extension.collectAsStateValue
import me.ash.reader.ui.extension.findActivity 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.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.ReadPage
import me.ash.reader.ui.page.home.read.ReadViewAction import me.ash.reader.ui.page.home.read.ReadViewAction
import me.ash.reader.ui.page.home.read.ReadViewModel import me.ash.reader.ui.page.home.read.ReadViewModel
@ -98,35 +98,10 @@ fun HomePage(
state = viewState.pagerState, state = viewState.pagerState,
composableList = listOf( composableList = listOf(
{ {
FeedsPage( FeedsPage(navController = navController)
navController = navController,
)
}, },
{ {
ArticlePage( FlowPage(navController = navController)
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,
)
)
},
)
}, },
{ {
ReadPage( ReadPage(

View File

@ -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
)
}
}
}
}
}

View File

@ -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
}
}
}
}
}
}
}

View File

@ -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
)
}
},
)
}

View File

@ -15,7 +15,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
fun Feed( fun FeedItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
name: String, name: String,
important: Int, important: Int,

View File

@ -2,12 +2,11 @@ package me.ash.reader.ui.page.home.feeds
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Add 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.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import me.ash.reader.data.constant.Filter
import me.ash.reader.data.constant.Symbol import me.ash.reader.data.constant.Symbol
import me.ash.reader.ui.extension.collectAsStateValue 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.HomeViewAction
import me.ash.reader.ui.page.home.HomeViewModel import me.ash.reader.ui.page.home.HomeViewModel
import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeDialog import me.ash.reader.ui.page.home.feeds.subscribe.SubscribeDialog
@ -47,6 +44,7 @@ fun FeedsPage(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewState = viewModel.viewState.collectAsStateValue() val viewState = viewModel.viewState.collectAsStateValue()
val filterState = homeViewModel.filterState.collectAsStateValue()
val syncState = homeViewModel.syncState.collectAsStateValue() val syncState = homeViewModel.syncState.collectAsStateValue()
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition()
@ -65,10 +63,7 @@ fun FeedsPage(
LaunchedEffect(homeViewModel.filterState) { LaunchedEffect(homeViewModel.filterState) {
homeViewModel.filterState.collect { state -> homeViewModel.filterState.collect { state ->
viewModel.dispatch( viewModel.dispatch(
FeedsViewAction.FetchData( FeedsViewAction.FetchData(state)
isStarred = state.filter.let { it != Filter.All && it == Filter.Starred },
isUnread = state.filter.let { it != Filter.All && it == Filter.Unread },
)
) )
} }
} }
@ -93,9 +88,7 @@ fun FeedsPage(
homeViewModel.dispatch(HomeViewAction.Sync()) homeViewModel.dispatch(HomeViewAction.Sync())
}) { }) {
Icon( Icon(
modifier = Modifier.graphicsLayer { modifier = Modifier.rotate(if (syncState.isSyncing) angle else 0f),
rotationZ = if (syncState.isSyncing) angle else 0f
},
imageVector = Icons.Rounded.Refresh, imageVector = Icons.Rounded.Refresh,
contentDescription = "Refresh", contentDescription = "Refresh",
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
@ -119,79 +112,96 @@ fun FeedsPage(
viewModel.dispatch(FeedsViewAction.AddFromFile(it)) viewModel.dispatch(FeedsViewAction.AddFromFile(it))
}, },
) )
Column( LazyColumn {
modifier = Modifier.verticalScroll(rememberScrollState()) item {
) { Text(
Text( modifier = Modifier.padding(
modifier = Modifier.padding( start = 24.dp,
start = 24.dp, top = 48.dp,
top = 48.dp, end = 24.dp,
end = 24.dp, bottom = 24.dp
bottom = 24.dp ),
), text = viewState.account?.name ?: Symbol.Unknown,
text = viewState.account?.name ?: Symbol.Unknown, style = MaterialTheme.typography.displaySmall,
style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface, )
) }
Banner( item {
title = viewState.filter.title, Banner(
desc = "${viewState.filter.important}${viewState.filter.description}", title = viewState.filter.name,
icon = viewState.filter.icon, desc = "${viewState.filter.important}${viewState.filter.description}",
action = { icon = viewState.filter.icon,
Icon( action = {
imageVector = Icons.Outlined.KeyboardArrowRight, Icon(
contentDescription = "Goto", imageVector = Icons.Outlined.KeyboardArrowRight,
tint = MaterialTheme.colorScheme.onSurface, contentDescription = "Goto",
tint = MaterialTheme.colorScheme.onSurface,
)
},
) {
homeViewModel.dispatch(
HomeViewAction.ChangeFilter(
filterState.copy(
group = null,
feed = null
)
)
) )
}, homeViewModel.dispatch(
) HomeViewAction.ScrollToPage(
Spacer(modifier = Modifier.height(24.dp)) scope = scope,
Subtitle( targetPage = 1,
modifier = Modifier.padding(start = 4.dp), )
text = "Feeds"
)
Spacer(modifier = Modifier.height(8.dp))
Column {
viewState.groupWithFeedList.forEachIndexed { index, groupWithFeed ->
Group(
text = groupWithFeed.group.name,
feeds = groupWithFeed.feeds,
groupOnClick = {
homeViewModel.dispatch(
HomeViewAction.ChangeFilter(
FilterState(
group = groupWithFeed.group,
feed = null,
)
)
)
homeViewModel.dispatch(
HomeViewAction.ScrollToPage(
scope = scope,
targetPage = 1,
)
)
},
feedOnClick = { feed ->
homeViewModel.dispatch(
HomeViewAction.ChangeFilter(
FilterState(
group = null,
feed = feed,
)
)
)
homeViewModel.dispatch(
HomeViewAction.ScrollToPage(
scope = scope,
targetPage = 1,
)
)
}
) )
if (index != viewState.groupWithFeedList.lastIndex) { }
Spacer(modifier = Modifier.height(8.dp)) }
item {
Spacer(modifier = Modifier.height(24.dp))
Subtitle(
modifier = Modifier.padding(start = 28.dp),
text = "Feeds"
)
Spacer(modifier = Modifier.height(8.dp))
}
itemsIndexed(viewState.groupWithFeedList) { index, groupWithFeed ->
GroupItem(
text = groupWithFeed.group.name,
feeds = groupWithFeed.feeds,
groupOnClick = {
homeViewModel.dispatch(
HomeViewAction.ChangeFilter(
filterState.copy(
group = groupWithFeed.group,
feed = null
)
)
)
homeViewModel.dispatch(
HomeViewAction.ScrollToPage(
scope = scope,
targetPage = 1,
)
)
},
feedOnClick = { feed ->
homeViewModel.dispatch(
HomeViewAction.ChangeFilter(
filterState.copy(
group = null,
feed = feed
)
)
)
homeViewModel.dispatch(
HomeViewAction.ScrollToPage(
scope = scope,
targetPage = 1,
)
)
} }
)
if (index != viewState.groupWithFeedList.lastIndex) {
Spacer(modifier = Modifier.height(8.dp))
} }
} }
} }

View File

@ -14,6 +14,7 @@ import me.ash.reader.data.group.GroupWithFeed
import me.ash.reader.data.repository.AccountRepository import me.ash.reader.data.repository.AccountRepository
import me.ash.reader.data.repository.OpmlRepository import me.ash.reader.data.repository.OpmlRepository
import me.ash.reader.data.repository.RssRepository import me.ash.reader.data.repository.RssRepository
import me.ash.reader.ui.page.home.FilterState
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
@ -29,7 +30,7 @@ class FeedsViewModel @Inject constructor(
fun dispatch(action: FeedsViewAction) { fun dispatch(action: FeedsViewAction) {
when (action) { when (action) {
is FeedsViewAction.FetchAccount -> fetchAccount(action.callback) 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.AddFromFile -> addFromFile(action.inputStream)
is FeedsViewAction.ScrollToItem -> scrollToItem(action.index) 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) { viewModelScope.launch(Dispatchers.IO) {
pullFeeds(isStarred, isUnread) pullFeeds(
isStarred = filterState.filter.isStarred(),
isUnread = filterState.filter.isUnread(),
)
_viewState _viewState
} }
} }
@ -135,8 +139,7 @@ data class FeedsViewState(
sealed class FeedsViewAction { sealed class FeedsViewAction {
data class FetchData( data class FetchData(
val isStarred: Boolean, val filterState: FilterState,
val isUnread: Boolean,
) : FeedsViewAction() ) : FeedsViewAction()
data class FetchAccount( data class FetchAccount(

View File

@ -20,7 +20,7 @@ import androidx.compose.ui.unit.dp
import me.ash.reader.data.feed.Feed import me.ash.reader.data.feed.Feed
@Composable @Composable
fun Group( fun GroupItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String, text: String,
feeds: List<Feed>, feeds: List<Feed>,
@ -37,7 +37,7 @@ fun Group(
.clip(RoundedCornerShape(32.dp)) .clip(RoundedCornerShape(32.dp))
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.14f)) .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.14f))
.clickable { groupOnClick() } .clickable { groupOnClick() }
.padding(top = 22.dp, bottom = if (expanded) 14.dp else 22.dp) .padding(vertical = 22.dp)
) { ) {
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@ -76,9 +76,11 @@ fun Group(
exit = fadeOut() + shrinkVertically(), exit = fadeOut() + shrinkVertically(),
) { ) {
Column { Column {
Spacer(modifier = Modifier.height(16.dp)) if (feeds.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
}
feeds.forEach { feed -> feeds.forEach { feed ->
Feed( FeedItem(
modifier = Modifier.padding(horizontal = 20.dp), modifier = Modifier.padding(horizontal = 20.dp),
name = feed.name, name = feed.name,
important = feed.important ?: 0, important = feed.important ?: 0,

View File

@ -2,9 +2,9 @@ package me.ash.reader.ui.page.home.feeds.subscribe
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Article import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Notifications
@ -13,14 +13,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.flowlayout.MainAxisAlignment import com.google.accompanist.flowlayout.MainAxisAlignment
import me.ash.reader.data.group.Group import me.ash.reader.data.group.Group
import me.ash.reader.ui.widget.SelectionChip import me.ash.reader.ui.widget.SelectionChip
import me.ash.reader.ui.widget.SelectionEditorChip
import me.ash.reader.ui.widget.Subtitle
@Composable @Composable
fun ResultViewPage( fun ResultViewPage(
@ -34,7 +33,9 @@ fun ResultViewPage(
groupOnClick: (groupId: String) -> Unit = {}, groupOnClick: (groupId: String) -> Unit = {},
onKeyboardAction: () -> Unit = {}, onKeyboardAction: () -> Unit = {},
) { ) {
Column { Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Link( Link(
text = link text = link
) )
@ -82,11 +83,7 @@ private fun Preset(
notificationPresetOnClick: () -> Unit = {}, notificationPresetOnClick: () -> Unit = {},
fullContentParsePresetOnClick: () -> Unit = {}, fullContentParsePresetOnClick: () -> Unit = {},
) { ) {
Text( Subtitle(text = "预设")
text = "预设",
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
FlowRow( FlowRow(
mainAxisAlignment = MainAxisAlignment.Start, mainAxisAlignment = MainAxisAlignment.Start,
@ -94,38 +91,36 @@ private fun Preset(
mainAxisSpacing = 10.dp, mainAxisSpacing = 10.dp,
) { ) {
SelectionChip( SelectionChip(
modifier = Modifier.animateContentSize(),
content = "接收通知",
selected = selectedNotificationPreset, selected = selectedNotificationPreset,
selectedIcon = { selectedIcon = {
Icon( Icon(
imageVector = Icons.Outlined.Notifications, imageVector = Icons.Outlined.Notifications,
contentDescription = "Check", contentDescription = "Check",
modifier = Modifier.size(20.dp) modifier = Modifier
.padding(start = 8.dp)
.size(18.dp),
) )
}, },
onClick = notificationPresetOnClick,
) { ) {
Text( notificationPresetOnClick()
text = "接收通知",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
} }
SelectionChip( SelectionChip(
modifier = Modifier.animateContentSize(),
content = "全文解析",
selected = selectedFullContentParsePreset, selected = selectedFullContentParsePreset,
selectedIcon = { selectedIcon = {
Icon( Icon(
imageVector = Icons.Outlined.Article, imageVector = Icons.Outlined.Article,
contentDescription = "Check", contentDescription = "Check",
modifier = Modifier.size(20.dp) modifier = Modifier
.padding(start = 8.dp)
.size(18.dp),
) )
}, },
onClick = fullContentParsePresetOnClick,
) { ) {
Text( fullContentParsePresetOnClick()
text = "全文解析",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
} }
} }
} }
@ -137,11 +132,7 @@ private fun AddToGroup(
groupOnClick: (groupId: String) -> Unit = {}, groupOnClick: (groupId: String) -> Unit = {},
onKeyboardAction: () -> Unit = {}, onKeyboardAction: () -> Unit = {},
) { ) {
Text( Subtitle(text = "添加到组")
text = "添加到组",
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
FlowRow( FlowRow(
mainAxisAlignment = MainAxisAlignment.Start, mainAxisAlignment = MainAxisAlignment.Start,
@ -151,37 +142,20 @@ private fun AddToGroup(
groups.forEach { groups.forEach {
SelectionChip( SelectionChip(
modifier = Modifier.animateContentSize(), modifier = Modifier.animateContentSize(),
content = it.name,
selected = it.id == selectedGroupId, selected = it.id == selectedGroupId,
onClick = { groupOnClick(it.id) },
) { ) {
Text( groupOnClick(it.id)
text = it.name,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
} }
} }
SelectionChip( SelectionEditorChip(
modifier = Modifier.animateContentSize(),
content = "新建分组",
selected = false, 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()
}
)
)
} }
} }
} }

View File

@ -13,6 +13,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.pager.ExperimentalPagerApi 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.extension.collectAsStateValue
import me.ash.reader.ui.widget.Dialog import me.ash.reader.ui.widget.Dialog
import java.io.InputStream import java.io.InputStream
@ -38,6 +42,10 @@ fun SubscribeDialog(
LaunchedEffect(viewState.visible) { LaunchedEffect(viewState.visible) {
if (viewState.visible) { if (viewState.visible) {
val defaultGroupId = context.dataStore
.get(DataStoreKeys.CurrentAccountId)!!
.spacerDollar("0")
viewModel.dispatch(SubscribeViewAction.SelectedGroup(defaultGroupId))
viewModel.dispatch(SubscribeViewAction.Init) viewModel.dispatch(SubscribeViewAction.Init)
} else { } else {
viewModel.dispatch(SubscribeViewAction.Reset) viewModel.dispatch(SubscribeViewAction.Reset)

View File

@ -127,6 +127,14 @@ class SubscribeViewModel @Inject constructor(
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
if (rssRepository.get().isExist(_viewState.value.inputContent)) {
_viewState.update {
it.copy(
errorMessage = "已订阅",
)
}
return@launch
}
val feedWithArticle = rssHelper.searchFeed(_viewState.value.inputContent) val feedWithArticle = rssHelper.searchFeed(_viewState.value.inputContent)
_viewState.update { _viewState.update {
it.copy( it.copy(

View File

@ -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,
)
}
}
}
}

View File

@ -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
}
}

View 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)
}
}
)
}

View File

@ -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.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -13,26 +13,22 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.ash.reader.data.article.ArticleWithFeed import me.ash.reader.data.article.ArticleWithFeed
import me.ash.reader.data.repository.RssRepository import me.ash.reader.data.repository.RssRepository
import me.ash.reader.ui.page.home.FilterState
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ArticleViewModel @Inject constructor( class FlowViewModel @Inject constructor(
private val rssRepository: RssRepository, private val rssRepository: RssRepository,
) : ViewModel() { ) : ViewModel() {
private val _viewState = MutableStateFlow(ArticleViewState()) private val _viewState = MutableStateFlow(ArticleViewState())
val viewState: StateFlow<ArticleViewState> = _viewState.asStateFlow() val viewState: StateFlow<ArticleViewState> = _viewState.asStateFlow()
fun dispatch(action: ArticleViewAction) { fun dispatch(action: FlowViewAction) {
when (action) { when (action) {
is ArticleViewAction.FetchData -> fetchData( is FlowViewAction.FetchData -> fetchData(action.filterState)
groupId = action.groupId, is FlowViewAction.ChangeRefreshing -> changeRefreshing(action.isRefreshing)
feedId = action.feedId, is FlowViewAction.ScrollToItem -> scrollToItem(action.index)
isStarred = action.isStarred, is FlowViewAction.PeekSyncWork -> peekSyncWork()
isUnread = action.isUnread,
)
is ArticleViewAction.ChangeRefreshing -> changeRefreshing(action.isRefreshing)
is ArticleViewAction.ScrollToItem -> scrollToItem(action.index)
is ArticleViewAction.PeekSyncWork -> peekSyncWork()
} }
} }
@ -44,14 +40,9 @@ class ArticleViewModel @Inject constructor(
} }
} }
private fun fetchData( private fun fetchData(filterState: FilterState) {
groupId: String? = null,
feedId: String? = null,
isStarred: Boolean,
isUnread: Boolean,
) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
rssRepository.get().pullImportant(isStarred, true) rssRepository.get().pullImportant(filterState.filter.isStarred(), true)
.collect { importantList -> .collect { importantList ->
_viewState.update { _viewState.update {
it.copy( it.copy(
@ -64,10 +55,10 @@ class ArticleViewModel @Inject constructor(
it.copy( it.copy(
pagingData = Pager(PagingConfig(pageSize = 10)) { pagingData = Pager(PagingConfig(pageSize = 10)) {
rssRepository.get().pullArticles( rssRepository.get().pullArticles(
groupId = groupId, groupId = filterState.group?.id,
feedId = feedId, feedId = filterState.feed?.id,
isStarred = isStarred, isStarred = filterState.filter.isStarred(),
isUnread = isUnread, isUnread = filterState.filter.isUnread(),
) )
}.flow.cachedIn(viewModelScope) }.flow.cachedIn(viewModelScope)
) )
@ -95,21 +86,18 @@ data class ArticleViewState(
val syncWorkInfo: String = "", val syncWorkInfo: String = "",
) )
sealed class ArticleViewAction { sealed class FlowViewAction {
data class FetchData( data class FetchData(
val groupId: String? = null, val filterState: FilterState,
val feedId: String? = null, ) : FlowViewAction()
val isStarred: Boolean,
val isUnread: Boolean,
) : ArticleViewAction()
data class ChangeRefreshing( data class ChangeRefreshing(
val isRefreshing: Boolean val isRefreshing: Boolean
) : ArticleViewAction() ) : FlowViewAction()
data class ScrollToItem( data class ScrollToItem(
val index: Int val index: Int
) : ArticleViewAction() ) : FlowViewAction()
object PeekSyncWork : ArticleViewAction() object PeekSyncWork : FlowViewAction()
} }

View File

@ -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.background
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable @Composable
fun ArticleDateHeader( fun StickyHeader(currentItemDay: String) {
date: String,
isDisplayIcon: Boolean
) {
Row( Row(
modifier = Modifier modifier = Modifier
.height(28.dp)
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface), .background(MaterialTheme.colorScheme.surface),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = date, modifier = Modifier.padding(start = if (true) 54.dp else 24.dp),
fontSize = 13.sp, text = currentItemDay,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = (if (isDisplayIcon) 52 else 20).dp), style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
) )
} }
} }

View File

@ -22,8 +22,8 @@ fun Banner(
title: String, title: String,
desc: String? = null, desc: String? = null,
icon: ImageVector? = null, icon: ImageVector? = null,
action: (@Composable () -> Unit)? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
action: (@Composable () -> Unit)? = null
) { ) {
Surface( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),

View File

@ -1,16 +1,20 @@
package me.ash.reader.ui.widget package me.ash.reader.ui.widget
import androidx.compose.foundation.interaction.MutableInteractionSource 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape 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.ChipDefaults
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FilterChip import androidx.compose.material.FilterChip
import androidx.compose.material.icons.Icons 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -20,20 +24,22 @@ import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun SelectionChip( fun SelectionChip(
content: String,
selected: Boolean, selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = CircleShape, shape: Shape = CircleShape,
selectedIcon: @Composable () -> Unit = { selectedIcon: @Composable () -> Unit = {
Icon( Icon(
imageVector = Icons.Outlined.Check, imageVector = Icons.Rounded.Check,
contentDescription = "Check", contentDescription = "Check",
modifier = Modifier.size(20.dp) modifier = Modifier
.padding(start = 8.dp)
.size(18.dp)
) )
}, },
content: @Composable RowScope.() -> Unit onClick: () -> Unit,
) { ) {
FilterChip( FilterChip(
modifier = modifier, modifier = modifier,
@ -54,6 +60,83 @@ fun SelectionChip(
selectedIcon = selectedIcon, selectedIcon = selectedIcon,
shape = shape, shape = shape,
onClick = onClick, 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()
}
)
)
},
) )
} }

View File

@ -11,15 +11,15 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun Subtitle( fun Subtitle(
text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
text: String,
color: Color = MaterialTheme.colorScheme.primary, color: Color = MaterialTheme.colorScheme.primary,
) { ) {
Text( Text(
text = text, text = text,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp, 8.dp, 16.dp, 8.dp), .padding(vertical = 8.dp),
color = color, color = color,
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge
) )