Add subscribe a single feed feature

This commit is contained in:
Ash 2022-03-08 01:11:01 +08:00
parent 11ca1f1ae8
commit 1713331125
25 changed files with 697 additions and 249 deletions

View File

@ -0,0 +1,18 @@
package me.ash.reader
fun String.formatUrl(): String {
if (this.startsWith("//")) {
return "https:$this"
}
val regex = Regex("^(https?|ftp|file).*")
return if (!regex.matches(this)) {
"https://$this"
} else {
this
}
}
fun String.isUrl(): Boolean {
val regex = Regex("(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]")
return regex.matches(this)
}

View File

@ -263,7 +263,8 @@ interface ArticleDao {
SELECT a.id, a.date, a.title, a.author, a.rawDescription,
a.shortDescription, a.fullContent, a.link, a.feedId,
a.accountId, a.isUnread, a.isStarred
FROM article AS a, feed AS b
FROM article AS a LEFT JOIN feed AS b
ON a.feedId = b.id
WHERE a.feedId = :feedId
AND a.accountId = :accountId
ORDER BY date DESC LIMIT 1

View File

@ -1,7 +1,8 @@
package me.ash.reader.data.constant
object Symbol {
const val NOTHING: String = "null"
const val NOTHING: String = "Null"
const val Unknown: String = "Unknown"
const val NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE: String = "article.update"
const val EXTRA_ARTICLE_ID: String = "article.id"
}

View File

@ -23,10 +23,12 @@ data class Feed(
@ColumnInfo
val url: String,
@ColumnInfo(index = true)
var groupId: Int,
var groupId: Int? = null,
@ColumnInfo(index = true)
val accountId: Int,
@ColumnInfo(defaultValue = "false")
var isNotification: Boolean = false,
@ColumnInfo(defaultValue = "false")
var isFullContent: Boolean = false,
) {
@Ignore
@ -47,6 +49,7 @@ data class Feed(
if (url != other.url) return false
if (groupId != other.groupId) return false
if (accountId != other.accountId) return false
if (isNotification != other.isNotification) return false
if (isFullContent != other.isFullContent) return false
if (important != other.important) return false
@ -58,8 +61,9 @@ data class Feed(
result = 31 * result + name.hashCode()
result = 31 * result + (icon?.contentHashCode() ?: 0)
result = 31 * result + url.hashCode()
result = 31 * result + groupId
result = 31 * result + (groupId ?: 0)
result = 31 * result + accountId
result = 31 * result + isNotification.hashCode()
result = 31 * result + isFullContent.hashCode()
result = 31 * result + (important ?: 0)
return result

View File

@ -13,7 +13,10 @@ interface FeedDao {
suspend fun queryAll(accountId: Int): List<Feed>
@Insert
suspend fun insertList(feed: List<Feed>): List<Long>
suspend fun insert(feed: Feed): Long
@Insert
suspend fun insertList(feeds: List<Feed>): List<Long>
@Update
suspend fun update(vararg feed: Feed)

View File

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

View File

@ -10,6 +10,9 @@ import me.ash.reader.data.article.Article
import me.ash.reader.data.article.ArticleDao
import me.ash.reader.data.article.ArticleWithFeed
import me.ash.reader.data.article.ImportantCount
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.feed.FeedDao
import me.ash.reader.data.group.Group
import me.ash.reader.data.group.GroupDao
import me.ash.reader.data.group.GroupWithFeed
import me.ash.reader.dataStore
@ -21,7 +24,12 @@ class ArticleRepository @Inject constructor(
private val context: Context,
private val articleDao: ArticleDao,
private val groupDao: GroupDao,
private val feedDao: FeedDao,
) {
fun pullGroups(): Flow<MutableList<Group>> {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
return groupDao.queryAllGroup(accountId)
}
fun pullFeeds(): Flow<MutableList<GroupWithFeed>> {
return groupDao.queryAllGroupWithFeed(
@ -90,4 +98,11 @@ class ArticleRepository @Inject constructor(
suspend fun findArticleById(id: Int): ArticleWithFeed? {
return articleDao.queryById(id)
}
suspend fun subscribe(feed: Feed, articles: List<Article>) {
val feedId = feedDao.insert(feed).toInt()
articleDao.insertList(articles.map {
it.copy(feedId = feedId)
})
}
}

View File

@ -11,7 +11,6 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.work.*
import com.github.muhrifqii.parserss.ParseRSS
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.flow.*
@ -25,12 +24,12 @@ import me.ash.reader.data.article.ArticleDao
import me.ash.reader.data.constant.Symbol
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.feed.FeedDao
import me.ash.reader.data.feed.FeedWithArticle
import me.ash.reader.data.source.ReaderDatabase
import me.ash.reader.data.source.RssNetworkDataSource
import net.dankito.readability4j.Readability4J
import net.dankito.readability4j.extended.Readability4JExtended
import okhttp3.*
import org.xmlpull.v1.XmlPullParserFactory
import java.io.IOException
import java.util.*
import java.util.concurrent.TimeUnit
@ -46,6 +45,38 @@ class RssRepository @Inject constructor(
private val rssNetworkDataSource: RssNetworkDataSource,
private val workManager: WorkManager,
) {
@Throws(Exception::class)
suspend fun searchFeed(feedLink: String): FeedWithArticle {
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 0
val parseRss = rssNetworkDataSource.parseRss(feedLink)
val feed = Feed(
name = parseRss.title!!,
url = feedLink,
groupId = 0,
accountId = accountId,
)
val articles = mutableListOf<Article>()
parseRss.items.forEach {
articles.add(
Article(
accountId = accountId,
feedId = feed.id ?: 0,
date = Date(it.publishDate.toString()),
title = it.title.toString(),
author = it.author,
rawDescription = it.description.toString(),
shortDescription = (Readability4JExtended("", it.description.toString())
.parse().textContent ?: "").trim().run {
if (this.length > 100) this.substring(0, 100)
else this
},
link = it.link ?: "",
)
)
}
return FeedWithArticle(feed, articles)
}
fun parseDescriptionContent(link: String, content: String): String {
val readability4J: Readability4J = Readability4JExtended(link, content)
val article = readability4J.parse()
@ -146,6 +177,10 @@ class RssRepository @Inject constructor(
val accountId = context.dataStore.get(DataStoreKeys.CurrentAccountId)
?: return
val feeds = feedDao.queryAll(accountId)
val feedNotificationMap = mutableMapOf<Int, Boolean>()
feeds.forEach { feed ->
feedNotificationMap[feed.id ?: 0] = feed.isNotification
}
val preTime = System.currentTimeMillis()
val chunked = feeds.chunked(6)
chunked.forEachIndexed { index, item ->
@ -199,15 +234,15 @@ class RssRepository @Inject constructor(
)
)
}
it.reversed().forEach { articleList ->
it.forEach { articleList ->
val ids = articleDao.insertList(articleList)
articleList.forEachIndexed { index, article ->
Log.i("RlOG", "combine ${article.feedId}: ${article.title}")
if (feedNotificationMap[article.feedId] == true) {
val builder = NotificationCompat.Builder(
context,
Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE
)
.setSmallIcon(R.drawable.ic_launcher_foreground)
).setSmallIcon(R.drawable.ic_launcher_foreground)
.setGroup(Symbol.NOTIFICATION_CHANNEL_GROUP_ARTICLE_UPDATE)
.setContentTitle(article.title)
.setContentText(article.shortDescription)
@ -219,14 +254,21 @@ class RssRepository @Inject constructor(
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(Symbol.EXTRA_ARTICLE_ID, ids[index].toInt())
putExtra(
Symbol.EXTRA_ARTICLE_ID,
ids[index].toInt()
)
},
PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
)
notificationManager.notify(ids[index].toInt(), builder.build().apply {
notificationManager.notify(
ids[index].toInt(),
builder.build().apply {
flags = Notification.FLAG_AUTO_CANCEL
})
}
)
}
}
}
}.buffer().onCompletion {
@ -256,7 +298,6 @@ class RssRepository @Inject constructor(
feed: Feed,
latestTitle: String? = null,
): List<Article> {
ParseRSS.init(XmlPullParserFactory.newInstance())
val a = mutableListOf<Article>()
try {
val parseRss = rssNetworkDataSource.parseRss(feed.url)

View File

@ -1,7 +1,9 @@
package me.ash.reader.data.source
import com.github.muhrifqii.parserss.ParseRSS
import com.github.muhrifqii.parserss.RSSFeedObject
import com.github.muhrifqii.parserss.retrofit.ParseRSSConverterFactory
import org.xmlpull.v1.XmlPullParserFactory
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Url
@ -15,6 +17,7 @@ interface RssNetworkDataSource {
fun getInstance(): RssNetworkDataSource {
return instance ?: synchronized(this) {
ParseRSS.init(XmlPullParserFactory.newInstance())
instance ?: Retrofit.Builder()
.baseUrl("https://api.feeddd.org/feeds/")
.addConverterFactory(ParseRSSConverterFactory.create<RSSFeedObject>())

View File

@ -0,0 +1,18 @@
package me.ash.reader.ui.extension
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
fun PagerState.animateScrollToPage(
scope: CoroutineScope,
targetPage: Int,
callback: () -> Unit = {}
) {
scope.launch {
animateScrollToPage(targetPage)
callback()
}
}

View File

@ -16,6 +16,7 @@ import me.ash.reader.data.constant.Filter
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.group.Group
import me.ash.reader.data.repository.RssRepository
import me.ash.reader.ui.extension.animateScrollToPage
import javax.inject.Inject
@OptIn(ExperimentalPagerApi::class)
@ -62,10 +63,7 @@ class HomeViewModel @Inject constructor(
}
private fun scrollToPage(scope: CoroutineScope, targetPage: Int, callback: () -> Unit = {}) {
scope.launch {
_viewState.value.pagerState.animateScrollToPage(targetPage)
callback()
}
_viewState.value.pagerState.animateScrollToPage(scope, targetPage, callback)
}
}
@ -76,7 +74,7 @@ data class FilterState(
)
@OptIn(ExperimentalPagerApi::class)
data class HomeViewState constructor(
data class HomeViewState(
val pagerState: PagerState = PagerState(1),
)

View File

@ -1,5 +1,6 @@
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
@ -9,6 +10,7 @@ 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(
@ -16,10 +18,14 @@ fun ArticlePageTopBar(
readAllOnClick: () -> Unit = {},
searchOnClick: () -> Unit = {},
) {
val view = LocalView.current
SmallTopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = backOnClick) {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
backOnClick()
}) {
Icon(
imageVector = Icons.Rounded.ArrowBackIosNew,
contentDescription = "Back",
@ -28,14 +34,20 @@ fun ArticlePageTopBar(
}
},
actions = {
IconButton(onClick = readAllOnClick) {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
readAllOnClick()
}) {
Icon(
imageVector = Icons.Rounded.DoneAll,
contentDescription = "Done All",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = searchOnClick) {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
searchOnClick()
}) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = "Search",

View File

@ -1,7 +1,6 @@
package me.ash.reader.ui.page.home.feed
import android.graphics.BitmapFactory
import android.util.Log
import androidx.compose.animation.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@ -24,7 +23,6 @@ fun ColumnScope.FeedList(
) {
Column(modifier = Modifier.animateContentSize()) {
feeds.forEach { feed ->
Log.i("RLog", "FeedList: ${feed.icon}")
FeedBar(
barButtonType = ItemType(
// icon = feed.icon ?: "",

View File

@ -23,6 +23,8 @@ 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.feed.subscribe.SubscribeDialog
import me.ash.reader.ui.page.home.feed.subscribe.SubscribeViewAction
import me.ash.reader.ui.page.home.feed.subscribe.SubscribeViewModel
import me.ash.reader.ui.widget.TopTitleBox
@Composable
@ -31,6 +33,7 @@ fun FeedPage(
modifier: Modifier = Modifier,
viewModel: FeedViewModel = hiltViewModel(),
homeViewModel: HomeViewModel = hiltViewModel(),
subscribeViewModel: SubscribeViewModel = hiltViewModel(),
filter: Filter,
groupAndFeedOnClick: (currentGroup: Group?, currentFeed: Feed?) -> Unit = { _, _ -> },
) {
@ -59,21 +62,6 @@ fun FeedPage(
modifier = modifier.fillMaxSize()
) {
SubscribeDialog(
visible = viewState.subscribeDialogVisible,
hiddenFunction = {
viewModel.dispatch(FeedViewAction.ChangeSubscribeDialogVisible(false))
},
inputContent = viewState.subscribeDialogFeedLink,
onValueChange = {
viewModel.dispatch(
FeedViewAction.InputSubscribeFeedLink(it)
)
},
onKeyboardAction = {
viewModel.dispatch(
FeedViewAction.ChangeSubscribeDialogVisible(false)
)
},
openInputStreamCallback = {
viewModel.dispatch(FeedViewAction.AddFromFile(it))
},
@ -102,7 +90,7 @@ fun FeedPage(
homeViewModel.dispatch(HomeViewAction.Sync())
},
subscribeOnClick = {
viewModel.dispatch(FeedViewAction.ChangeSubscribeDialogVisible(true))
subscribeViewModel.dispatch(SubscribeViewAction.Show)
},
)
LazyColumn(

View File

@ -1,5 +1,6 @@
package me.ash.reader.ui.page.home.feed
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
@ -11,6 +12,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import me.ash.reader.ui.page.common.RouteName
@ -22,10 +24,12 @@ fun FeedPageTopBar(
syncOnClick: () -> Unit = {},
subscribeOnClick: () -> Unit = {},
) {
val view = LocalView.current
SmallTopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
navController.navigate(route = RouteName.SETTINGS)
}) {
Icon(
@ -38,6 +42,7 @@ fun FeedPageTopBar(
},
actions = {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
if (isSyncing) return@IconButton
syncOnClick()
}) {
@ -48,7 +53,10 @@ fun FeedPageTopBar(
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = subscribeOnClick) {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
subscribeOnClick()
}) {
Icon(
modifier = Modifier.size(26.dp),
imageVector = Icons.Rounded.Add,

View File

@ -33,28 +33,6 @@ class FeedViewModel @Inject constructor(
is FeedViewAction.ChangeFeedVisible -> changeFeedVisible(action.index)
is FeedViewAction.ChangeGroupVisible -> changeGroupVisible(action.visible)
is FeedViewAction.ScrollToItem -> scrollToItem(action.index)
is FeedViewAction.ChangeSubscribeDialogVisible -> changeAddFeedDialogVisible(action.visible)
is FeedViewAction.InputSubscribeFeedLink -> inputSubscribeFeedLink(action.subscribeFeedLink)
}
}
private fun inputSubscribeFeedLink(subscribeFeedLink: String) {
viewModelScope.launch {
_viewState.update {
it.copy(
subscribeDialogFeedLink = subscribeFeedLink
)
}
}
}
private fun changeAddFeedDialogVisible(visible: Boolean) {
viewModelScope.launch {
_viewState.update {
it.copy(
subscribeDialogVisible = visible
)
}
}
}
@ -165,8 +143,6 @@ data class FeedViewState(
val feedsVisible: List<Boolean> = emptyList(),
val listState: LazyListState = LazyListState(),
val groupsVisible: Boolean = true,
var subscribeDialogVisible: Boolean = false,
var subscribeDialogFeedLink: String = "",
)
sealed class FeedViewAction {
@ -194,12 +170,4 @@ sealed class FeedViewAction {
data class ScrollToItem(
val index: Int
) : FeedViewAction()
data class ChangeSubscribeDialogVisible(
val visible: Boolean
) : FeedViewAction()
data class InputSubscribeFeedLink(
val subscribeFeedLink: String
) : FeedViewAction()
}

View File

@ -1,7 +1,9 @@
package me.ash.reader.ui.page.home.feed.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.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Article
@ -17,31 +19,56 @@ 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
@Composable
fun ResultViewPage() {
fun ResultViewPage(
link: String = "",
groups: List<Group> = emptyList(),
selectedNotificationPreset: Boolean = false,
selectedFullContentParsePreset: Boolean = false,
selectedGroupId: Int = 0,
notificationPresetOnClick: () -> Unit = {},
fullContentParsePresetOnClick: () -> Unit = {},
groupOnClick: (groupId: Int) -> Unit = {},
onKeyboardAction: () -> Unit = {},
) {
Column {
Link()
Link(
text = link
)
Spacer(modifier = Modifier.height(26.dp))
Preset()
Preset(
selectedNotificationPreset = selectedNotificationPreset,
selectedFullContentParsePreset = selectedFullContentParsePreset,
notificationPresetOnClick = notificationPresetOnClick,
fullContentParsePresetOnClick = fullContentParsePresetOnClick,
)
Spacer(modifier = Modifier.height(26.dp))
AddToGroup()
AddToGroup(
groups = groups,
selectedGroupId = selectedGroupId,
groupOnClick = groupOnClick,
onKeyboardAction = onKeyboardAction,
)
Spacer(modifier = Modifier.height(6.dp))
}
}
@Composable
private fun Link() {
private fun Link(
text: String,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
SelectionContainer {
Text(
text = "https://material.io/feed.xml",
text = text,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
@ -49,7 +76,12 @@ private fun Link() {
}
@Composable
private fun Preset() {
private fun Preset(
selectedNotificationPreset: Boolean = false,
selectedFullContentParsePreset: Boolean = false,
notificationPresetOnClick: () -> Unit = {},
fullContentParsePresetOnClick: () -> Unit = {},
) {
Text(
text = "预设",
color = MaterialTheme.colorScheme.primary,
@ -62,7 +94,7 @@ private fun Preset() {
mainAxisSpacing = 10.dp,
) {
SelectionChip(
selected = true,
selected = selectedNotificationPreset,
selectedIcon = {
Icon(
imageVector = Icons.Outlined.Notifications,
@ -70,7 +102,7 @@ private fun Preset() {
modifier = Modifier.size(20.dp)
)
},
onClick = { /*TODO*/ },
onClick = notificationPresetOnClick,
) {
Text(
text = "接收通知",
@ -79,7 +111,7 @@ private fun Preset() {
)
}
SelectionChip(
selected = false,
selected = selectedFullContentParsePreset,
selectedIcon = {
Icon(
imageVector = Icons.Outlined.Article,
@ -87,10 +119,10 @@ private fun Preset() {
modifier = Modifier.size(20.dp)
)
},
onClick = { /*TODO*/ }
onClick = fullContentParsePresetOnClick,
) {
Text(
text = "全文输出",
text = "全文解析",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
@ -99,7 +131,12 @@ private fun Preset() {
}
@Composable
private fun AddToGroup() {
private fun AddToGroup(
groups: List<Group>,
selectedGroupId: Int,
groupOnClick: (groupId: Int) -> Unit = {},
onKeyboardAction: () -> Unit = {},
) {
Text(
text = "添加到组",
color = MaterialTheme.colorScheme.primary,
@ -111,49 +148,23 @@ private fun AddToGroup() {
crossAxisSpacing = 10.dp,
mainAxisSpacing = 10.dp,
) {
groups.forEach {
SelectionChip(
modifier = Modifier.animateContentSize(),
selected = it.id == selectedGroupId,
onClick = { groupOnClick(it.id ?: 0) },
) {
Text(
text = it.name,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
}
}
SelectionChip(
selected = false,
onClick = { /*TODO*/ },
) {
Text(
text = "未分组",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
}
SelectionChip(
selected = true,
onClick = { /*TODO*/ }
) {
Text(
text = "技术",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
}
SelectionChip(
selected = true,
onClick = { /*TODO*/ }
) {
Text(
text = "新鲜事",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
}
SelectionChip(
selected = false,
onClick = { /*TODO*/ }
) {
Text(
text = "游戏",
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
}
SelectionChip(
selected = true,
onClick = { /*TODO*/ },
) {
BasicTextField(
modifier = Modifier.width(56.dp),
@ -165,6 +176,11 @@ private fun AddToGroup() {
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
),
singleLine = true,
keyboardActions = KeyboardActions(
onDone = {
onKeyboardAction()
}
)
)
}
}

View File

@ -1,11 +1,15 @@
package me.ash.reader.ui.page.home.feed.subscribe
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.text.KeyboardActions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ContentPaste
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -24,6 +28,7 @@ import kotlinx.coroutines.delay
@Composable
fun SearchViewPage(
inputContent: String = "",
errorMessage: String = "",
onValueChange: (String) -> Unit = {},
onKeyboardAction: () -> Unit = {},
) {
@ -34,6 +39,7 @@ fun SearchViewPage(
focusRequester.requestFocus()
}
Column {
Spacer(modifier = Modifier.height(10.dp))
TextField(
modifier = Modifier.focusRequester(focusRequester),
@ -53,10 +59,21 @@ fun SearchViewPage(
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)
)
},
isError = errorMessage.isNotEmpty(),
singleLine = true,
trailingIcon = {
if (inputContent.isNotEmpty()) {
IconButton(onClick = {
onValueChange("")
}) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
)
}
} else {
IconButton(onClick = {
// focusRequester.requestFocus()
}) {
Icon(
imageVector = Icons.Rounded.ContentPaste,
@ -64,6 +81,7 @@ fun SearchViewPage(
tint = MaterialTheme.colorScheme.primary
)
}
}
},
keyboardActions = KeyboardActions(
onDone = {
@ -71,5 +89,17 @@ fun SearchViewPage(
}
)
)
if (errorMessage.isNotEmpty()) {
SelectionContainer {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 16.dp),
maxLines = 1,
softWrap = false,
)
}
}
Spacer(modifier = Modifier.height(10.dp))
}
}

View File

@ -8,22 +8,23 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
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.ui.extension.collectAsStateValue
import me.ash.reader.ui.widget.Dialog
import java.io.InputStream
@OptIn(ExperimentalPagerApi::class)
@Composable
fun SubscribeDialog(
visible: Boolean,
hiddenFunction: () -> Unit,
inputContent: String = "",
onValueChange: (String) -> Unit = {},
onKeyboardAction: () -> Unit = {},
viewModel: SubscribeViewModel = hiltViewModel(),
openInputStreamCallback: (InputStream) -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
it?.let { uri ->
context.contentResolver.openInputStream(uri)?.let { inputStream ->
@ -31,50 +32,124 @@ fun SubscribeDialog(
}
}
}
val viewState = viewModel.viewState.collectAsStateValue()
val groupsState = viewState.groups.collectAsState(initial = emptyList())
var height by remember { mutableStateOf(0) }
LaunchedEffect(viewState.visible) {
if (viewState.visible) {
viewModel.dispatch(SubscribeViewAction.Init)
} else {
viewModel.dispatch(SubscribeViewAction.Reset)
viewState.pagerState.scrollToPage(0)
}
}
Dialog(
visible = visible,
onDismissRequest = hiddenFunction,
visible = viewState.visible,
onDismissRequest = {
viewModel.dispatch(SubscribeViewAction.Hide)
},
icon = {
Icon(
imageVector = Icons.Rounded.RssFeed,
contentDescription = "Subscribe",
)
},
title = { Text("订阅") },
title = {
Text(
when (viewState.pagerState.currentPage) {
0 -> "订阅"
else -> viewState.feed?.name ?: "未知"
}
)
},
text = {
SubscribeViewPager(
inputContent = inputContent,
onValueChange = onValueChange,
onKeyboardAction = onKeyboardAction,
// height = when (viewState.pagerState.currentPage) {
// 0 -> 84.dp
// else -> Dp.Unspecified
// },
inputContent = viewState.inputContent,
errorMessage = viewState.errorMessage,
onValueChange = {
viewModel.dispatch(SubscribeViewAction.Input(it))
},
onSearchKeyboardAction = {
viewModel.dispatch(SubscribeViewAction.Search(scope))
},
link = viewState.inputContent,
groups = groupsState.value,
selectedNotificationPreset = viewState.notificationPreset,
selectedFullContentParsePreset = viewState.fullContentParsePreset,
selectedGroupId = viewState.selectedGroupId ?: 0,
pagerState = viewState.pagerState,
notificationPresetOnClick = {
viewModel.dispatch(SubscribeViewAction.ChangeNotificationPreset)
},
fullContentParsePresetOnClick = {
viewModel.dispatch(SubscribeViewAction.ChangeFullContentParsePreset)
},
groupOnClick = {
viewModel.dispatch(SubscribeViewAction.SelectedGroup(it))
},
onResultKeyboardAction = {
viewModel.dispatch(SubscribeViewAction.Subscribe)
}
)
},
confirmButton = {
when (viewState.pagerState.currentPage) {
0 -> {
TextButton(
enabled = inputContent.isNotEmpty(),
enabled = viewState.inputContent.isNotEmpty(),
onClick = {
hiddenFunction()
viewModel.dispatch(SubscribeViewAction.Search(scope))
}
) {
Text(
text = "搜索",
color = if (inputContent.isNotEmpty()) {
color = if (viewState.inputContent.isNotEmpty()) {
Color.Unspecified
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.7f)
}
)
}
}
1 -> {
TextButton(
onClick = {
viewModel.dispatch(SubscribeViewAction.Subscribe)
}
) {
Text("订阅")
}
}
}
},
dismissButton = {
when (viewState.pagerState.currentPage) {
0 -> {
TextButton(
onClick = {
launcher.launch("*/*")
hiddenFunction()
viewModel.dispatch(SubscribeViewAction.Hide)
}
) {
Text("导入OPML文件")
}
}
1 -> {
TextButton(
onClick = {
viewModel.dispatch(SubscribeViewAction.Hide)
}
) {
Text("取消")
}
}
}
},
)
}

View File

@ -0,0 +1,205 @@
package me.ash.reader.ui.page.home.feed.subscribe
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import me.ash.reader.data.article.Article
import me.ash.reader.data.constant.Symbol
import me.ash.reader.data.feed.Feed
import me.ash.reader.data.group.Group
import me.ash.reader.data.repository.ArticleRepository
import me.ash.reader.data.repository.RssRepository
import me.ash.reader.formatUrl
import me.ash.reader.ui.extension.animateScrollToPage
import javax.inject.Inject
@OptIn(ExperimentalPagerApi::class)
@HiltViewModel
class SubscribeViewModel @Inject constructor(
private val articleRepository: ArticleRepository,
private val rssRepository: RssRepository,
) : ViewModel() {
private val _viewState = MutableStateFlow(SubScribeViewState())
val viewState: StateFlow<SubScribeViewState> = _viewState.asStateFlow()
fun dispatch(action: SubscribeViewAction) {
when (action) {
is SubscribeViewAction.Init -> init()
is SubscribeViewAction.Reset -> reset()
is SubscribeViewAction.Show -> changeVisible(true)
is SubscribeViewAction.Hide -> changeVisible(false)
is SubscribeViewAction.Input -> inputLink(action.content)
is SubscribeViewAction.Search -> search(action.scope)
is SubscribeViewAction.ChangeNotificationPreset ->
changeNotificationPreset()
is SubscribeViewAction.ChangeFullContentParsePreset ->
changeFullContentParsePreset()
is SubscribeViewAction.SelectedGroup -> selectedGroup(action.groupId)
is SubscribeViewAction.Subscribe -> subscribe()
}
}
private fun init() {
_viewState.update {
it.copy(
groups = articleRepository.pullGroups()
)
}
}
private fun reset() {
_viewState.update {
it.copy(
visible = false,
title = "订阅",
errorMessage = "",
inputContent = "",
feed = null,
articles = emptyList(),
notificationPreset = false,
fullContentParsePreset = false,
selectedGroupId = null,
groups = emptyFlow(),
)
}
}
private fun subscribe() {
val feed = _viewState.value.feed ?: return
val articles = _viewState.value.articles
val groupId = _viewState.value.selectedGroupId ?: 0
viewModelScope.launch(Dispatchers.IO) {
articleRepository.subscribe(
feed.copy(
groupId = groupId,
isNotification = _viewState.value.notificationPreset,
isFullContent = _viewState.value.fullContentParsePreset,
), articles
)
changeVisible(false)
}
}
private fun selectedGroup(groupId: Int? = null) {
_viewState.update {
it.copy(
selectedGroupId = groupId,
)
}
}
private fun changeFullContentParsePreset() {
_viewState.update {
it.copy(
fullContentParsePreset = !_viewState.value.fullContentParsePreset
)
}
}
private fun changeNotificationPreset() {
_viewState.update {
it.copy(
notificationPreset = !_viewState.value.notificationPreset
)
}
}
private fun search(scope: CoroutineScope) {
_viewState.value.inputContent.formatUrl().let { str ->
if (str != _viewState.value.inputContent) {
_viewState.update {
it.copy(
inputContent = str
)
}
}
}
_viewState.update {
it.copy(
title = "搜索中",
)
}
viewModelScope.launch(Dispatchers.IO) {
try {
val feedWithArticle = rssRepository.searchFeed(_viewState.value.inputContent)
_viewState.update {
it.copy(
feed = feedWithArticle.feed,
articles = feedWithArticle.articles,
)
}
_viewState.value.pagerState.animateScrollToPage(scope, 1)
} catch (e: Exception) {
e.printStackTrace()
_viewState.update {
it.copy(
title = "订阅",
errorMessage = e.message ?: Symbol.Unknown,
)
}
}
}
}
private fun inputLink(content: String) {
_viewState.update {
it.copy(
inputContent = content
)
}
}
private fun changeVisible(visible: Boolean) {
_viewState.update {
it.copy(
visible = visible
)
}
}
}
@OptIn(ExperimentalPagerApi::class)
data class SubScribeViewState(
val visible: Boolean = false,
val title: String = "订阅",
val errorMessage: String = "",
val inputContent: String = "",
val feed: Feed? = null,
val articles: List<Article> = emptyList(),
val notificationPreset: Boolean = false,
val fullContentParsePreset: Boolean = false,
val selectedGroupId: Int? = null,
val groups: Flow<List<Group>> = emptyFlow(),
val pagerState: PagerState = PagerState(),
)
sealed class SubscribeViewAction {
object Init : SubscribeViewAction()
object Reset : SubscribeViewAction()
object Show : SubscribeViewAction()
object Hide : SubscribeViewAction()
data class Input(
val content: String
) : SubscribeViewAction()
data class Search(
val scope: CoroutineScope,
) : SubscribeViewAction()
object ChangeNotificationPreset : SubscribeViewAction()
object ChangeFullContentParsePreset : SubscribeViewAction()
data class SelectedGroup(
val groupId: Int? = null
) : SubscribeViewAction()
object Subscribe : SubscribeViewAction()
}

View File

@ -1,27 +1,58 @@
package me.ash.reader.ui.page.home.feed.subscribe
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import me.ash.reader.data.group.Group
import me.ash.reader.ui.widget.ViewPager
@OptIn(ExperimentalPagerApi::class)
@Composable
fun SubscribeViewPager(
height: Dp = Dp.Unspecified,
inputContent: String = "",
errorMessage: String = "",
onValueChange: (String) -> Unit = {},
onKeyboardAction: () -> Unit = {}
onSearchKeyboardAction: () -> Unit = {},
link: String = "",
groups: List<Group> = emptyList(),
selectedNotificationPreset: Boolean = false,
selectedFullContentParsePreset: Boolean = false,
selectedGroupId: Int = 0,
pagerState: PagerState = com.google.accompanist.pager.rememberPagerState(),
notificationPresetOnClick: () -> Unit = {},
fullContentParsePresetOnClick: () -> Unit = {},
groupOnClick: (groupId: Int) -> Unit = {},
onResultKeyboardAction: () -> Unit = {},
) {
ViewPager(
modifier = Modifier.height(height),
state = pagerState,
userScrollEnabled = false,
composableList = listOf(
{
SearchViewPage(
inputContent = inputContent,
errorMessage = errorMessage,
onValueChange = onValueChange,
onKeyboardAction = onKeyboardAction,
onKeyboardAction = onSearchKeyboardAction,
)
},
{
ResultViewPage()
ResultViewPage(
link = link,
groups = groups,
selectedNotificationPreset = selectedNotificationPreset,
selectedFullContentParsePreset = selectedFullContentParsePreset,
selectedGroupId = selectedGroupId,
notificationPresetOnClick = notificationPresetOnClick,
fullContentParsePresetOnClick = fullContentParsePresetOnClick,
groupOnClick = groupOnClick,
onKeyboardAction = onResultKeyboardAction,
)
}
)
)

View File

@ -3,7 +3,6 @@ package me.ash.reader.ui.page.home.read
import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -74,7 +73,6 @@ fun ReadPage(
exit = fadeOut() + shrinkVertically(),
) {
if (viewState.articleWithFeed == null) return@AnimatedVisibility
SelectionContainer {
LazyColumn(
state = viewState.listState,
modifier = Modifier
@ -105,4 +103,3 @@ fun ReadPage(
}
}
}
}

View File

@ -1,5 +1,6 @@
package me.ash.reader.ui.page.home.read
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
@ -11,16 +12,21 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
@Composable
fun ReadPageTopBar(
btnBackOnClickListener: () -> Unit = {},
) {
val view = LocalView.current
SmallTopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = { btnBackOnClickListener() }) {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
btnBackOnClickListener()
}) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Rounded.Close,
@ -30,19 +36,23 @@ fun ReadPageTopBar(
}
},
actions = {
IconButton(onClick = { /*TODO*/ }) {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Rounded.Share,
contentDescription = "Add",
contentDescription = "Share",
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = { /*TODO*/ }) {
IconButton(onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Rounded.MoreHoriz,
contentDescription = "Add",
contentDescription = "More",
tint = MaterialTheme.colorScheme.primary,
)
}

View File

@ -1,6 +1,5 @@
package me.ash.reader.ui.widget
import androidx.compose.animation.*
import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.Composable
@ -14,11 +13,12 @@ fun Dialog(
confirmButton: @Composable () -> Unit,
dismissButton: @Composable (() -> Unit)? = null,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
// AnimatedVisibility(
// visible = visible,
// enter = fadeIn() + expandVertically(),
// exit = fadeOut() + shrinkVertically(),
// ) {
if (visible) {
AlertDialog(
onDismissRequest = onDismissRequest,
icon = icon,

View File

@ -16,7 +16,7 @@ fun ViewPager(
modifier: Modifier = Modifier,
state: PagerState = com.google.accompanist.pager.rememberPagerState(),
composableList: List<@Composable () -> Unit>,
userScrollEnabled: Boolean = true
userScrollEnabled: Boolean = true,
) {
HorizontalPager(
count = composableList.size,