Improve SyncState

This commit is contained in:
Ash 2022-04-03 18:15:28 +08:00
parent 54506e5019
commit 5f11616c6a
12 changed files with 296 additions and 232 deletions

View File

@ -48,8 +48,8 @@ class App : Application(), Configuration.Provider {
@Inject @Inject
lateinit var localRssRepository: LocalRssRepository lateinit var localRssRepository: LocalRssRepository
@Inject // @Inject
lateinit var feverRssRepository: FeverRssRepository // lateinit var feverRssRepository: FeverRssRepository
@Inject @Inject
lateinit var opmlRepository: OpmlRepository lateinit var opmlRepository: OpmlRepository

View File

@ -8,8 +8,8 @@ import androidx.work.*
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.flow.flowOn
import me.ash.reader.data.dao.AccountDao import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao import me.ash.reader.data.dao.FeedDao
@ -17,6 +17,7 @@ import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.entity.* import me.ash.reader.data.entity.*
import me.ash.reader.data.source.RssNetworkDataSource import me.ash.reader.data.source.RssNetworkDataSource
import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.currentAccountId
import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
abstract class AbstractRssRepository constructor( abstract class AbstractRssRepository constructor(
@ -29,22 +30,13 @@ abstract class AbstractRssRepository constructor(
private val workManager: WorkManager, private val workManager: WorkManager,
private val dispatcherIO: CoroutineDispatcher, private val dispatcherIO: CoroutineDispatcher,
) { ) {
data class SyncState(
val feedCount: Int = 0,
val syncedCount: Int = 0,
val currentFeedName: String = "",
) {
val isSyncing: Boolean = feedCount != 0 || syncedCount != 0 || currentFeedName != ""
val isNotSyncing: Boolean = !isSyncing
}
abstract suspend fun updateArticleInfo(article: Article) abstract suspend fun updateArticleInfo(article: Article)
abstract suspend fun subscribe(feed: Feed, articles: List<Article>) abstract suspend fun subscribe(feed: Feed, articles: List<Article>)
abstract suspend fun addGroup(name: String): String abstract suspend fun addGroup(name: String): String
abstract suspend fun sync() abstract suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result
fun doSync() { fun doSync() {
workManager.enqueueUniquePeriodicWork( workManager.enqueueUniquePeriodicWork(
@ -148,17 +140,6 @@ abstract class AbstractRssRepository constructor(
articleDao.deleteByFeedId(context.currentAccountId, feed.id) articleDao.deleteByFeedId(context.currentAccountId, feed.id)
feedDao.delete(feed) feedDao.delete(feed)
} }
companion object {
val mutex = Mutex()
private val _syncState = MutableStateFlow(SyncState())
val syncState = _syncState.asStateFlow()
fun updateSyncState(function: (SyncState) -> SyncState) {
_syncState.update(function)
}
}
} }
@HiltWorker @HiltWorker
@ -170,18 +151,24 @@ class SyncWorker @AssistedInject constructor(
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Log.i("RLog", "doWork: ") Log.i("RLog", "doWork: ")
rssRepository.get().sync() return rssRepository.get().sync(this)
return Result.success()
} }
companion object { companion object {
const val WORK_NAME = "article.sync" const val WORK_NAME = "article.sync"
val UUID: UUID
val repeatingRequest = PeriodicWorkRequestBuilder<SyncWorker>( val repeatingRequest = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES 15, TimeUnit.MINUTES
).setConstraints( ).setConstraints(
Constraints.Builder() Constraints.Builder()
.build() .build()
).addTag(WORK_NAME).build() ).addTag(WORK_NAME).build().also {
UUID = it.id
}
fun setIsSyncing(boolean: Boolean) = workDataOf("isSyncing" to boolean)
fun Data.getIsSyncing(): Boolean = getBoolean("isSyncing", false)
} }
} }

View File

@ -1,163 +1,163 @@
package me.ash.reader.data.repository //package me.ash.reader.data.repository
//
import android.content.Context //import android.content.Context
import android.util.Log //import android.util.Log
import androidx.work.WorkManager //import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext //import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher //import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope //import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch //import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock //import kotlinx.coroutines.sync.withLock
import me.ash.reader.data.dao.AccountDao //import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao //import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao //import me.ash.reader.data.dao.FeedDao
import me.ash.reader.data.dao.GroupDao //import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.entity.Article //import me.ash.reader.data.entity.Article
import me.ash.reader.data.entity.Feed //import me.ash.reader.data.entity.Feed
import me.ash.reader.data.entity.Group //import me.ash.reader.data.entity.Group
import me.ash.reader.data.module.ApplicationScope //import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.module.DispatcherDefault //import me.ash.reader.data.module.DispatcherDefault
import me.ash.reader.data.module.DispatcherIO //import me.ash.reader.data.module.DispatcherIO
import me.ash.reader.data.source.FeverApiDataSource //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.ui.ext.currentAccountId //import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.spacerDollar //import me.ash.reader.ui.ext.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
import kotlin.collections.set //import kotlin.collections.set
//
class FeverRssRepository @Inject constructor( //class FeverRssRepository @Inject constructor(
@ApplicationContext // @ApplicationContext
private val context: Context, // private val context: Context,
private val articleDao: ArticleDao, // private val articleDao: ArticleDao,
private val feedDao: FeedDao, // private val feedDao: FeedDao,
private val groupDao: GroupDao, // private val groupDao: GroupDao,
private val rssHelper: RssHelper, // private val rssHelper: RssHelper,
private val feverApiDataSource: FeverApiDataSource, // private val feverApiDataSource: FeverApiDataSource,
private val accountDao: AccountDao, // private val accountDao: AccountDao,
rssNetworkDataSource: RssNetworkDataSource, // rssNetworkDataSource: RssNetworkDataSource,
@ApplicationScope // @ApplicationScope
private val applicationScope: CoroutineScope, // private val applicationScope: CoroutineScope,
@DispatcherDefault // @DispatcherDefault
private val dispatcherDefault: CoroutineDispatcher, // private val dispatcherDefault: CoroutineDispatcher,
@DispatcherIO // @DispatcherIO
private val dispatcherIO: CoroutineDispatcher, // private val dispatcherIO: CoroutineDispatcher,
workManager: WorkManager, // workManager: WorkManager,
) : AbstractRssRepository( //) : AbstractRssRepository(
context, accountDao, articleDao, groupDao, // context, accountDao, articleDao, groupDao,
feedDao, rssNetworkDataSource, workManager, // feedDao, rssNetworkDataSource, workManager,
dispatcherIO // dispatcherIO
) { //) {
override suspend fun updateArticleInfo(article: Article) { // override suspend fun updateArticleInfo(article: Article) {
articleDao.update(article) // articleDao.update(article)
} // }
//
override suspend fun subscribe(feed: Feed, articles: List<Article>) { // override suspend fun subscribe(feed: Feed, articles: List<Article>) {
feedDao.insert(feed) // feedDao.insert(feed)
articleDao.insertList(articles.map { // articleDao.insertList(articles.map {
it.copy(feedId = feed.id) // it.copy(feedId = feed.id)
}) // })
} // }
//
override suspend fun addGroup(name: String): String { // override suspend fun addGroup(name: String): String {
return UUID.randomUUID().toString().also { // return UUID.randomUUID().toString().also {
groupDao.insert( // groupDao.insert(
Group( // Group(
id = it, // id = it,
name = name, // name = name,
accountId = context.currentAccountId // accountId = context.currentAccountId
) // )
) // )
} // }
} // }
//
override suspend fun sync() { // override suspend fun sync() {
applicationScope.launch(dispatcherDefault) { // applicationScope.launch(dispatcherDefault) {
mutex.withLock { // mutex.withLock {
val accountId = context.currentAccountId // val accountId = context.currentAccountId
//
updateSyncState { // updateSyncState {
it.copy( // it.copy(
feedCount = 1, // feedCount = 1,
syncedCount = 1, // syncedCount = 1,
currentFeedName = "Fever" // currentFeedName = "Fever"
) // )
} // }
//
if (feedDao.queryAll(accountId).isNullOrEmpty()) { // if (feedDao.queryAll(accountId).isNullOrEmpty()) {
// Temporary add feeds // // Temporary add feeds
val feverFeeds = feverApiDataSource.feeds().execute().body()!!.feeds // val feverFeeds = feverApiDataSource.feeds().execute().body()!!.feeds
val feverGroupsBody = feverApiDataSource.groups().execute().body()!! // val feverGroupsBody = feverApiDataSource.groups().execute().body()!!
Log.i("RLog", "Fever groups: $feverGroupsBody") // Log.i("RLog", "Fever groups: $feverGroupsBody")
feverGroupsBody.groups.forEach { // feverGroupsBody.groups.forEach {
groupDao.insert( // groupDao.insert(
Group( // Group(
id = accountId.spacerDollar(it.id), // id = accountId.spacerDollar(it.id),
name = it.title, // name = it.title,
accountId = accountId, // accountId = accountId,
) // )
) // )
} // }
val feverFeedsGroupsMap = mutableMapOf<Int, Int>() // val feverFeedsGroupsMap = mutableMapOf<Int, Int>()
feverGroupsBody.feeds_groups.forEach { item -> // feverGroupsBody.feeds_groups.forEach { item ->
item.feed_ids // item.feed_ids
.split(",") // .split(",")
.map { it.toInt() } // .map { it.toInt() }
.forEach { id -> // .forEach { id ->
feverFeedsGroupsMap[id] = item.group_id // feverFeedsGroupsMap[id] = item.group_id
} // }
} // }
val feeds = feverFeeds.map { // val feeds = feverFeeds.map {
Feed( // Feed(
id = accountId.spacerDollar(it.id), // 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(),
accountId = accountId // accountId = accountId
) // )
} // }
feedDao.insertList(feeds) // feedDao.insertList(feeds)
} // }
//
// Add articles // // Add articles
val articles = mutableListOf<Article>() // val articles = mutableListOf<Article>()
feverApiDataSource.itemsBySince(since = 1647444325925621L) // feverApiDataSource.itemsBySince(since = 1647444325925621L)
.execute().body()!!.items // .execute().body()!!.items
.forEach { // .forEach {
articles.add( // articles.add(
Article( // Article(
id = accountId.spacerDollar(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,
rawDescription = it.html, // rawDescription = it.html,
shortDescription = ( // shortDescription = (
Readability4JExtended("", it.html) // Readability4JExtended("", it.html)
.parse().textContent ?: "" // .parse().textContent ?: ""
).take(100).trim(), // ).take(100).trim(),
link = it.url, // link = it.url,
accountId = accountId, // accountId = accountId,
feedId = it.feed_id.toString(), // feedId = it.feed_id.toString(),
isUnread = it.is_read == 0, // isUnread = it.is_read == 0,
isStarred = it.is_saved == 1, // isStarred = it.is_saved == 1,
) // )
) // )
} // }
articleDao.insertList(articles) // articleDao.insertList(articles)
//
// Complete sync // // Complete sync
accountDao.update(accountDao.queryById(accountId)!!.apply { // accountDao.update(accountDao.queryById(accountId)!!.apply {
updateAt = Date() // updateAt = Date()
}) // })
updateSyncState { // updateSyncState {
it.copy( // it.copy(
feedCount = 0, // feedCount = 0,
syncedCount = 0, // syncedCount = 0,
currentFeedName = "" // currentFeedName = ""
) // )
} // }
} // }
} // }
} // }
} //}

View File

@ -9,9 +9,14 @@ import android.content.Intent
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat.getSystemService import androidx.core.content.ContextCompat.getSystemService
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkManager import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import me.ash.reader.MainActivity import me.ash.reader.MainActivity
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.data.dao.AccountDao import me.ash.reader.data.dao.AccountDao
@ -21,9 +26,9 @@ import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.entity.Article import me.ash.reader.data.entity.Article
import me.ash.reader.data.entity.Feed import me.ash.reader.data.entity.Feed
import me.ash.reader.data.entity.Group import me.ash.reader.data.entity.Group
import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.module.DispatcherDefault import me.ash.reader.data.module.DispatcherDefault
import me.ash.reader.data.module.DispatcherIO import me.ash.reader.data.module.DispatcherIO
import me.ash.reader.data.repository.SyncWorker.Companion.setIsSyncing
import me.ash.reader.data.source.RssNetworkDataSource import me.ash.reader.data.source.RssNetworkDataSource
import me.ash.reader.ui.ext.currentAccountId import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.page.common.ExtraName import me.ash.reader.ui.page.common.ExtraName
@ -40,8 +45,6 @@ class LocalRssRepository @Inject constructor(
private val rssNetworkDataSource: RssNetworkDataSource, private val rssNetworkDataSource: RssNetworkDataSource,
private val accountDao: AccountDao, private val accountDao: AccountDao,
private val groupDao: GroupDao, private val groupDao: GroupDao,
@ApplicationScope
private val applicationScope: CoroutineScope,
@DispatcherDefault @DispatcherDefault
private val dispatcherDefault: CoroutineDispatcher, private val dispatcherDefault: CoroutineDispatcher,
@DispatcherIO @DispatcherIO
@ -89,13 +92,13 @@ class LocalRssRepository @Inject constructor(
} }
} }
override suspend fun sync() { override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result {
applicationScope.launch(dispatcherDefault) { return withContext(dispatcherDefault) {
val preTime = System.currentTimeMillis() val preTime = System.currentTimeMillis()
val accountId = context.currentAccountId val accountId = context.currentAccountId
val articles = mutableListOf<Article>() val articles = mutableListOf<Article>()
feedDao.queryAll(accountId) feedDao.queryAll(accountId)
.also { feed -> updateSyncState { it.copy(feedCount = feed.size) } } .also { coroutineWorker.setProgress(setIsSyncing(true)) }
.map { feed -> async { syncFeed(feed) } } .map { feed -> async { syncFeed(feed) } }
.awaitAll() .awaitAll()
.forEach { .forEach {
@ -114,14 +117,9 @@ class LocalRssRepository @Inject constructor(
} }
) )
} }
updateSyncState { coroutineWorker.setProgress(setIsSyncing(false))
it.copy( ListenableWorker.Result.success()
feedCount = 0,
syncedCount = 0,
currentFeedName = ""
)
} }
}.join()
} }
data class ArticleNotify( data class ArticleNotify(
@ -146,12 +144,6 @@ class LocalRssRepository @Inject constructor(
Log.e("RLog", "queryRssIcon[${feed.name}]: ${e.message}") Log.e("RLog", "queryRssIcon[${feed.name}]: ${e.message}")
return ArticleNotify(listOf(), false) return ArticleNotify(listOf(), false)
} }
updateSyncState {
it.copy(
syncedCount = it.syncedCount + 1,
currentFeedName = feed.name
)
}
return ArticleNotify( return ArticleNotify(
articles = articles, articles = articles,
isNotify = articles.isNotEmpty() && feed.isNotification isNotify = articles.isNotEmpty() && feed.isNotification

View File

@ -10,13 +10,13 @@ class RssRepository @Inject constructor(
@ApplicationContext @ApplicationContext
private val context: Context, private val context: Context,
private val localRssRepository: LocalRssRepository, private val localRssRepository: LocalRssRepository,
private val feverRssRepository: FeverRssRepository, // private val feverRssRepository: FeverRssRepository,
// private val googleReaderRssRepository: GoogleReaderRssRepository, // private val googleReaderRssRepository: GoogleReaderRssRepository,
) { ) {
fun get() = when (context.currentAccountType) { fun get() = when (context.currentAccountType) {
Account.Type.LOCAL -> localRssRepository Account.Type.LOCAL -> localRssRepository
// Account.Type.LOCAL -> feverRssRepository // Account.Type.LOCAL -> feverRssRepository
Account.Type.FEVER -> feverRssRepository // Account.Type.FEVER -> feverRssRepository
// Account.Type.GOOGLE_READER -> googleReaderRssRepository // Account.Type.GOOGLE_READER -> googleReaderRssRepository
else -> throw IllegalStateException("Unknown account type: ${context.currentAccountType}") else -> throw IllegalStateException("Unknown account type: ${context.currentAccountType}")
} }

View File

@ -36,7 +36,6 @@ fun HomePage(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewState = homeViewModel.viewState.collectAsStateValue() val viewState = homeViewModel.viewState.collectAsStateValue()
val filterState = homeViewModel.filterState.collectAsStateValue() val filterState = homeViewModel.filterState.collectAsStateValue()
val syncState = homeViewModel.syncState.collectAsStateValue()
var openArticleId by rememberSaveable { var openArticleId by rememberSaveable {
mutableStateOf(intent?.extras?.get(ExtraName.ARTICLE_ID)?.toString() ?: "") mutableStateOf(intent?.extras?.get(ExtraName.ARTICLE_ID)?.toString() ?: "")
@ -95,8 +94,8 @@ fun HomePage(
{ {
FeedsPage( FeedsPage(
navController = navController, navController = navController,
syncWorkLiveData = homeViewModel.syncWorkLiveData,
filterState = filterState, filterState = filterState,
syncState = syncState,
onSyncClick = { onSyncClick = {
homeViewModel.dispatch(HomeViewAction.Sync) homeViewModel.dispatch(HomeViewAction.Sync)
}, },
@ -116,6 +115,7 @@ fun HomePage(
{ {
FlowPage( FlowPage(
navController = navController, navController = navController,
syncWorkLiveData = homeViewModel.syncWorkLiveData,
filterState = filterState, filterState = filterState,
onScrollToPage = { onScrollToPage = {
homeViewModel.dispatch( homeViewModel.dispatch(

View File

@ -1,6 +1,7 @@
package me.ash.reader.ui.page.home package me.ash.reader.ui.page.home
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.work.WorkManager
import com.google.accompanist.pager.ExperimentalPagerApi import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState import com.google.accompanist.pager.PagerState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -12,8 +13,8 @@ import kotlinx.coroutines.flow.update
import me.ash.reader.data.entity.Feed import me.ash.reader.data.entity.Feed
import me.ash.reader.data.entity.Filter import me.ash.reader.data.entity.Filter
import me.ash.reader.data.entity.Group import me.ash.reader.data.entity.Group
import me.ash.reader.data.repository.AbstractRssRepository
import me.ash.reader.data.repository.RssRepository import me.ash.reader.data.repository.RssRepository
import me.ash.reader.data.repository.SyncWorker
import me.ash.reader.ui.ext.animateScrollToPage import me.ash.reader.ui.ext.animateScrollToPage
import javax.inject.Inject import javax.inject.Inject
@ -21,6 +22,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
private val rssRepository: RssRepository, private val rssRepository: RssRepository,
private val workManager: WorkManager,
) : ViewModel() { ) : ViewModel() {
private val _viewState = MutableStateFlow(HomeViewState()) private val _viewState = MutableStateFlow(HomeViewState())
@ -29,7 +31,7 @@ class HomeViewModel @Inject constructor(
private val _filterState = MutableStateFlow(FilterState()) private val _filterState = MutableStateFlow(FilterState())
val filterState = _filterState.asStateFlow() val filterState = _filterState.asStateFlow()
val syncState = AbstractRssRepository.syncState val syncWorkLiveData = workManager.getWorkInfoByIdLiveData(SyncWorker.UUID)
fun dispatch(action: HomeViewAction) { fun dispatch(action: HomeViewAction) {
when (action) { when (action) {

View File

@ -2,6 +2,7 @@ package me.ash.reader.ui.page.home.feeds
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@ -14,20 +15,21 @@ import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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.lifecycle.LiveData
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.work.WorkInfo
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.data.repository.AbstractRssRepository import me.ash.reader.data.repository.SyncWorker.Companion.getIsSyncing
import me.ash.reader.ui.component.Banner import me.ash.reader.ui.component.Banner
import me.ash.reader.ui.component.Subtitle import me.ash.reader.ui.component.Subtitle
import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.collectAsStateValue
@ -48,8 +50,8 @@ fun FeedsPage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navController: NavHostController, navController: NavHostController,
feedsViewModel: FeedsViewModel = hiltViewModel(), feedsViewModel: FeedsViewModel = hiltViewModel(),
syncWorkLiveData: LiveData<WorkInfo>,
filterState: FilterState, filterState: FilterState,
syncState: AbstractRssRepository.SyncState,
subscribeViewModel: SubscribeViewModel = hiltViewModel(), subscribeViewModel: SubscribeViewModel = hiltViewModel(),
onSyncClick: () -> Unit = {}, onSyncClick: () -> Unit = {},
onFilterChange: (filterState: FilterState) -> Unit = {}, onFilterChange: (filterState: FilterState) -> Unit = {},
@ -58,6 +60,12 @@ fun FeedsPage(
val context = LocalContext.current val context = LocalContext.current
val viewState = feedsViewModel.viewState.collectAsStateValue() val viewState = feedsViewModel.viewState.collectAsStateValue()
val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) }
syncWorkLiveData.observe(owner) {
it?.let { isSyncing = it.progress.getIsSyncing() }
}
val infiniteTransition = rememberInfiniteTransition() val infiniteTransition = rememberInfiniteTransition()
val angle by infiniteTransition.animateFloat( val angle by infiniteTransition.animateFloat(
initialValue = 0f, initialValue = 0f,
@ -108,12 +116,12 @@ fun FeedsPage(
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
if (syncState.isNotSyncing) { if (!isSyncing) {
onSyncClick() onSyncClick()
} }
}) { }) {
Icon( Icon(
modifier = Modifier.rotate(if (syncState.isSyncing) angle else 0f), modifier = Modifier.rotate(if (isSyncing) angle else 0f),
imageVector = Icons.Rounded.Refresh, imageVector = Icons.Rounded.Refresh,
contentDescription = stringResource(R.string.refresh), contentDescription = stringResource(R.string.refresh),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
@ -142,7 +150,7 @@ fun FeedsPage(
start = 24.dp, start = 24.dp,
top = 48.dp, top = 48.dp,
end = 24.dp, end = 24.dp,
bottom = 24.dp // bottom = 24.dp
) )
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
@ -158,6 +166,26 @@ fun FeedsPage(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
item {
AnimatedVisibility(
visible = isSyncing,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Text(
modifier = Modifier.padding(
start = 24.dp,
top = 0.dp,
end = 24.dp,
bottom = 0.dp
),
text = stringResource(R.string.syncing),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.height(24.dp))
}
item { item {
Banner( Banner(
title = filterState.filter.getName(), title = filterState.filter.getName(),

View File

@ -5,6 +5,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Icon
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
@ -13,8 +16,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.ash.reader.R
import me.ash.reader.data.entity.ArticleWithFeed import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.ui.ext.formatAsString import me.ash.reader.ui.ext.formatAsString
@ -49,13 +54,30 @@ fun ArticleItem(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Text( Row(
modifier = Modifier.padding(start = 6.dp), modifier = Modifier.padding(start = 6.dp),
text = articleWithFeed.article.date.formatAsString(context, onlyHourMinute = true), verticalAlignment = Alignment.CenterVertically,
) {
if (articleWithFeed.article.isStarred) {
Icon(
modifier = Modifier
.size(14.dp)
.padding(end = 2.dp),
imageVector = Icons.Rounded.Star,
contentDescription = stringResource(R.string.starred),
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
Text(
text = articleWithFeed.article.date.formatAsString(
context,
onlyHourMinute = true
),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
) )
} }
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {

View File

@ -15,16 +15,20 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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.lifecycle.LiveData
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.work.WorkInfo
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.data.entity.ArticleWithFeed import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.data.repository.SyncWorker.Companion.getIsSyncing
import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.getName import me.ash.reader.ui.ext.getName
import me.ash.reader.ui.page.home.FilterBar import me.ash.reader.ui.page.home.FilterBar
@ -39,6 +43,7 @@ fun FlowPage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navController: NavHostController, navController: NavHostController,
flowViewModel: FlowViewModel = hiltViewModel(), flowViewModel: FlowViewModel = hiltViewModel(),
syncWorkLiveData: LiveData<WorkInfo>,
filterState: FilterState, filterState: FilterState,
onFilterChange: (filterState: FilterState) -> Unit = {}, onFilterChange: (filterState: FilterState) -> Unit = {},
onScrollToPage: (targetPage: Int) -> Unit = {}, onScrollToPage: (targetPage: Int) -> Unit = {},
@ -50,6 +55,12 @@ fun FlowPage(
val pagingItems = viewState.pagingData.collectAsLazyPagingItems() val pagingItems = viewState.pagingData.collectAsLazyPagingItems()
var markAsRead by remember { mutableStateOf(false) } var markAsRead by remember { mutableStateOf(false) }
val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) }
syncWorkLiveData.observe(owner) {
it?.let { isSyncing = it.progress.getIsSyncing() }
}
LaunchedEffect(filterState) { LaunchedEffect(filterState) {
flowViewModel.dispatch( flowViewModel.dispatch(
FlowViewAction.FetchData(filterState) FlowViewAction.FetchData(filterState)
@ -138,7 +149,7 @@ fun FlowPage(
start = if (true) 54.dp else 24.dp, start = if (true) 54.dp else 24.dp,
top = 48.dp, top = 48.dp,
end = 24.dp, end = 24.dp,
bottom = 24.dp // bottom = 24.dp
), ),
text = when { text = when {
filterState.group != null -> filterState.group.name filterState.group != null -> filterState.group.name
@ -151,6 +162,26 @@ fun FlowPage(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
item {
AnimatedVisibility(
visible = isSyncing,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Text(
modifier = Modifier.padding(
start = if (true) 54.dp else 24.dp,
top = 0.dp,
end = 24.dp,
bottom = 0.dp
),
text = stringResource(R.string.syncing),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.height(24.dp))
}
item { item {
AnimatedVisibility( AnimatedVisibility(
visible = markAsRead, visible = markAsRead,

View File

@ -7,6 +7,7 @@
<string name="starred">已加星标</string> <string name="starred">已加星标</string>
<string name="starred_desc">%1$d 项已加星标</string> <string name="starred_desc">%1$d 项已加星标</string>
<string name="feeds">分组</string> <string name="feeds">分组</string>
<string name="syncing">正在同步…</string>
<string name="expand_less">收缩</string> <string name="expand_less">收缩</string>
<string name="expand_more">展开</string> <string name="expand_more">展开</string>
<string name="confirm">确认</string> <string name="confirm">确认</string>

View File

@ -7,6 +7,7 @@
<string name="starred">Starred</string> <string name="starred">Starred</string>
<string name="starred_desc">%1$d Starred Items</string> <string name="starred_desc">%1$d Starred Items</string>
<string name="feeds">Feeds</string> <string name="feeds">Feeds</string>
<string name="syncing">Syncing…</string>
<string name="expand_less">Expand Less</string> <string name="expand_less">Expand Less</string>
<string name="expand_more">Expand More</string> <string name="expand_more">Expand More</string>
<string name="confirm">Confirm</string> <string name="confirm">Confirm</string>