Merge branch 'develop' into fix-freshrss-get-link

This commit is contained in:
Alexandre Alapetite 2024-03-31 19:23:59 +02:00
commit 9c73738cab
No known key found for this signature in database
GPG Key ID: 3A7556452A07908C
51 changed files with 863 additions and 446 deletions

View File

@ -54,8 +54,8 @@ dependencies {
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
implementation 'org.redundent:kotlin-xml-builder:1.7.3'

View File

@ -4,13 +4,14 @@ import com.readrops.api.utils.exceptions.HttpException
import okhttp3.Interceptor
import okhttp3.Response
class ErrorInterceptor() : Interceptor {
class ErrorInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (!response.isSuccessful) {
// TODO cover all cases
if (!response.isSuccessful && response.code != 304) {
throw HttpException(response)
}

View File

@ -87,8 +87,12 @@ dependencies {
implementation "androidx.fragment:fragment-ktx:1.3.5"
implementation "androidx.browser:browser:1.3.0"
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
/*
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
*/
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'com.github.bumptech.glide:compiler:4.12.0'
@ -113,20 +117,5 @@ dependencies {
debugImplementation 'com.facebook.soloader:soloader:0.10.1'
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.96.1'
def composeBom = platform('androidx.compose:compose-bom:2022.12.00')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.activity:activity-compose:1.5.1'
implementation 'androidx.compose.material3:material3'
def voyager = "1.0.0-rc03"
implementation "cafe.adriel.voyager:voyager-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-bottom-sheet-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-tab-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-androidx:$voyager"
//implementation "cafe.adriel.voyager:voyager-koin:$voyager"
debugImplementation "androidx.compose.ui:ui-tooling:1.3.3"
implementation "androidx.compose.ui:ui-tooling-preview:1.3.3"
implementation(libs.bundles.room)
}

View File

@ -62,7 +62,7 @@ import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Folder;
import com.readrops.db.entities.Item;
import com.readrops.db.entities.account.Account;
import com.readrops.db.filters.FilterType;
import com.readrops.db.filters.MainFilter;
import com.readrops.db.filters.ListSortType;
import com.readrops.db.pojo.ItemWithFeed;
@ -273,18 +273,18 @@ public class MainActivity extends AppCompatActivity implements SwipeRefreshLayou
switch (id) {
default:
case DrawerManager.ARTICLES_ITEM_ID:
viewModel.setFilterType(FilterType.NO_FILTER);
viewModel.setFilterType(MainFilter.ALL);
scrollToTop = true;
viewModel.invalidate();
setTitle(R.string.articles);
break;
case DrawerManager.READ_LATER_ID:
viewModel.setFilterType(FilterType.READ_IT_LATER_FILTER);
//viewModel.setFilterType(FilterType.READ_IT_LATER_FILTER);
viewModel.invalidate();
setTitle(R.string.read_later);
break;
case DrawerManager.STARS_ID:
viewModel.setFilterType(FilterType.STARS_FILTER);
viewModel.setFilterType(MainFilter.STARS);
viewModel.invalidate();
setTitle(R.string.favorites);
break;
@ -302,14 +302,14 @@ public class MainActivity extends AppCompatActivity implements SwipeRefreshLayou
drawer.closeDrawer();
viewModel.setFilterFeedId((int) drawerItem.getIdentifier());
viewModel.setFilterType(FilterType.FEED_FILTER);
viewModel.setFilterType(MainFilter.ALL);
viewModel.invalidate();
setTitle(((SecondaryDrawerItem) drawerItem).getName().getText());
} else if (drawerItem instanceof CustomExpandableBadgeDrawerItem) {
drawer.closeDrawer();
viewModel.setFilerFolderId((int) (drawerItem.getIdentifier() / 1000));
viewModel.setFilterType(FilterType.FOLDER_FILER);
viewModel.setFilterType(MainFilter.ALL);
viewModel.invalidate();
setTitle(((CustomExpandableBadgeDrawerItem) drawerItem).getName().getText());
}

View File

@ -17,7 +17,7 @@ import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Folder;
import com.readrops.db.entities.Item;
import com.readrops.db.entities.account.Account;
import com.readrops.db.filters.FilterType;
import com.readrops.db.filters.MainFilter;
import com.readrops.db.filters.ListSortType;
import com.readrops.db.pojo.ItemWithFeed;
import com.readrops.db.queries.ItemsQueryBuilder;
@ -54,8 +54,8 @@ public class MainViewModel extends ViewModel {
itemsWithFeed = new MediatorLiveData<>();
queryFilters = new QueryFilters();
queryFilters.setShowReadItems(SharedPreferencesManager.readBoolean(
SharedPreferencesManager.SharedPrefKey.SHOW_READ_ARTICLES));
/* queryFilters.setShowReadItems(SharedPreferencesManager.readBoolean(
SharedPreferencesManager.SharedPrefKey.SHOW_READ_ARTICLES));*/
}
//region main query
@ -71,7 +71,7 @@ public class MainViewModel extends ViewModel {
}
DataSource.Factory<Integer, ItemWithFeed> items;
items = database.itemDao().selectAll(ItemsQueryBuilder.buildItemsQuery(queryFilters, currentAccount.getConfig().getUseSeparateState()));
items = database.itemDao().selectAll(ItemsQueryBuilder.INSTANCE.buildItemsQuery(queryFilters, currentAccount.getConfig().getUseSeparateState()));
lastFetch = new LivePagedListBuilder<>(new RoomFactoryWrapper<>(items),
new PagedList.Config.Builder()
@ -89,23 +89,23 @@ public class MainViewModel extends ViewModel {
}
public void setShowReadItems(boolean showReadItems) {
queryFilters.setShowReadItems(showReadItems);
//queryFilters.setShowReadItems(showReadItems);
}
public boolean showReadItems() {
return queryFilters.getShowReadItems();
}
public void setFilterType(FilterType filterType) {
queryFilters.setFilterType(filterType);
public void setFilterType(MainFilter filterType) {
//queryFilters.setMainFilter(filterType);
}
public FilterType getFilterType() {
return queryFilters.getFilterType();
public MainFilter getFilterType() {
return queryFilters.getMainFilter();
}
public void setSortType(ListSortType sortType) {
queryFilters.setSortType(sortType);
//queryFilters.setSortType(sortType);
}
public ListSortType getSortType() {
@ -113,11 +113,11 @@ public class MainViewModel extends ViewModel {
}
public void setFilterFeedId(int filterFeedId) {
queryFilters.setFilterFeedId(filterFeedId);
//queryFilters.setFilterFeedId(filterFeedId);
}
public void setFilerFolderId(int folderId) {
queryFilters.setFilterFolderId(folderId);
//queryFilters.setFilterFolderId(folderId);
}
public MediatorLiveData<PagedList<ItemWithFeed>> getItemsWithFeed() {
@ -128,7 +128,7 @@ public class MainViewModel extends ViewModel {
itemsWithFeed.removeSource(lastFetch);
// get current viewed feed
if (feeds == null && queryFilters.getFilterType() == FilterType.FEED_FILTER) {
if (feeds == null && queryFilters.getMainFilter() == MainFilter.ALL) {
return Single.<Feed>create(emitter -> emitter.onSuccess(database.feedDao()
.getFeedById(queryFilters.getFilterFeedId())))
.flatMapCompletable(feed -> repository.sync(Collections.singletonList(feed), update));
@ -181,7 +181,7 @@ public class MainViewModel extends ViewModel {
public void setCurrentAccount(Account currentAccount) {
this.currentAccount = currentAccount;
setRepository();
queryFilters.setAccountId(currentAccount.getId());
//queryFilters.setAccountId(currentAccount.getId());
buildPagedList();
// set the new account as the current one
@ -212,7 +212,7 @@ public class MainViewModel extends ViewModel {
currentAccountExists = true;
setRepository();
queryFilters.setAccountId(currentAccount.getId());
//queryFilters.setAccountId(currentAccount.getId());
buildPagedList();
break;
}
@ -252,7 +252,7 @@ public class MainViewModel extends ViewModel {
}
public Completable setAllItemsReadState(boolean read) {
if (queryFilters.getFilterType() == FilterType.FEED_FILTER)
if (queryFilters.getMainFilter() == MainFilter.ALL)
return repository.setAllFeedItemsReadState(queryFilters.getFilterFeedId(), read);
else
return repository.setAllItemsReadState(read);

View File

@ -68,50 +68,22 @@ dependencies {
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
def composeBom = platform('androidx.compose:compose-bom:2023.10.01')
implementation composeBom
androidTestImplementation composeBom
implementation(libs.bundles.compose)
implementation(libs.compose.activity)
implementation 'androidx.palette:palette-ktx:1.0.0'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation 'androidx.compose.material3:material3'
implementation "com.google.accompanist:accompanist-swiperefresh:0.30.1"
implementation(libs.bundles.voyager)
implementation(libs.bundles.lifecycle)
implementation(libs.bundles.coil)
def voyager = "1.0.0-rc03"
implementation "cafe.adriel.voyager:voyager-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-bottom-sheet-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-tab-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-androidx:$voyager"
implementation "cafe.adriel.voyager:voyager-koin:$voyager"
implementation "cafe.adriel.voyager:voyager-transitions:$voyager"
implementation(libs.bundles.coroutines)
androidTestImplementation(libs.coroutines.test)
debugImplementation "androidx.compose.ui:ui-tooling:1.4.3"
implementation "androidx.compose.ui:ui-tooling-preview:1.4.3"
implementation(libs.bundles.room)
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
/*def koin_version = "3.3.3"
implementation "io.insert-koin:koin-core:$koin_version"
implementation "io.insert-koin:koin-android:$koin_version"
implementation "io.insert-koin:koin-androidx-compose:3.4.2"*/
androidTestImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
androidTestImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
implementation(libs.bundles.koin)
androidTestImplementation(libs.bundles.kointest)
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
implementation "io.coil-kt:coil:2.4.0"
implementation "io.coil-kt:coil-compose:2.4.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.1"
}

View File

@ -10,7 +10,8 @@ import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.launch
import com.readrops.db.filters.MainFilter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.joda.time.LocalDateTime
import org.junit.Before
@ -48,31 +49,36 @@ class GetFoldersWithFeedsTest {
.insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id))
}
// inserting 3 items linked to first feed (Feed 0)
// inserting 3 unread items linked to first feed (Feed 0)
repeat(3) { time ->
database.newItemDao()
.insert(Item(title = "Item $time", feedId = 1, pubDate = LocalDateTime.now()))
}
// insert 3 read items items linked to second feed (feed 1)
repeat(3) { time ->
database.newItemDao()
.insert(
Item(
title = "Item ${time + 3}",
feedId = 3,
isRead = true,
pubDate = LocalDateTime.now()
)
)
}
}
}
@Test
fun getFoldersWithFeedsTest() = runTest {
getFoldersWithFeeds = GetFoldersWithFeeds(database)
val job = launch {
getFoldersWithFeeds.get(account.id)
.collect { foldersAndFeeds ->
val foldersAndFeeds = getFoldersWithFeeds.get(account.id, MainFilter.ALL).first()
assertTrue { foldersAndFeeds.size == 4 }
assertTrue { foldersAndFeeds.entries.first().value.size == 2 }
assertTrue { foldersAndFeeds.entries.last().key == null }
assertTrue { foldersAndFeeds[null]!!.size == 2 }
assertTrue { foldersAndFeeds[null]!!.first().unreadCount == 3 }
}
}
// for an unknown reason, the coroutine must be canceled to stop the test, and I don't really know why
job.cancel()
assertTrue { foldersAndFeeds.size == 4 }
assertTrue { foldersAndFeeds.entries.first().value.size == 2 }
assertTrue { foldersAndFeeds.entries.last().key == null }
assertTrue { foldersAndFeeds[null]!!.size == 2 }
assertTrue { foldersAndFeeds[null]!!.first().unreadCount == 3 }
}
}

View File

@ -85,7 +85,7 @@ class LocalRSSRepositoryTest : KoinTest {
.setBody(Buffer().readFrom(stream))
)
val result = repository.synchronize(null) {
val result = repository.synchronize(listOf()) {
assertEquals(it.name, feeds.first().name)
}

View File

@ -1,25 +1,24 @@
package com.readrops.app.compose
import com.readrops.app.compose.account.AccountViewModel
import com.readrops.app.compose.account.AccountScreenModel
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.feeds.FeedScreenModel
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.app.compose.repositories.LocalRSSRepository
import com.readrops.app.compose.timelime.TimelineViewModel
import com.readrops.app.compose.timelime.TimelineScreenModel
import com.readrops.db.entities.account.Account
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val composeAppModule = module {
viewModel { TimelineViewModel(get(), get()) }
factory { TimelineScreenModel(get(), get()) }
viewModel { FeedViewModel(get(), get(), get()) }
factory { FeedScreenModel(get(), get(), get()) }
viewModel { AccountSelectionViewModel(get()) }
factory { AccountSelectionViewModel(get()) }
viewModel { AccountViewModel(get()) }
factory { AccountScreenModel(get()) }
single { GetFoldersWithFeeds(get()) }

View File

@ -3,10 +3,9 @@ package com.readrops.app.compose
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.material3.*
import cafe.adriel.voyager.navigator.CurrentScreen
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
import com.readrops.app.compose.account.selection.AccountSelectionScreen
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
import com.readrops.app.compose.home.HomeScreen
@ -15,7 +14,6 @@ import org.koin.androidx.viewmodel.ext.android.getViewModel
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -25,7 +23,11 @@ class MainActivity : ComponentActivity() {
setContent {
ReadropsTheme {
Navigator(
screen = if (accountExists) HomeScreen() else AccountSelectionScreen()
screen = if (accountExists) HomeScreen() else AccountSelectionScreen(),
disposeBehavior = NavigatorDisposeBehavior(
// prevent screenModels being recreated when opening a screen from a tab
disposeNestedNavigators = false
)
) {
CurrentScreen()
}

View File

@ -1,7 +1,7 @@
package com.readrops.app.compose.account
import androidx.lifecycle.viewModelScope
import com.readrops.app.compose.base.TabViewModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.compose.base.TabScreenModel
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
@ -11,9 +11,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AccountViewModel(
class AccountScreenModel(
private val database: Database
) : TabViewModel(database) {
) : TabScreenModel(database) {
private val _closeHome = MutableStateFlow(false)
val closeHome = _closeHome.asStateFlow()
@ -22,7 +22,7 @@ class AccountViewModel(
val accountState = _accountState.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
screenModelScope.launch(Dispatchers.IO) {
accountEvent.collect { account ->
_accountState.update {
it.copy(
@ -38,7 +38,7 @@ class AccountViewModel(
fun closeDialog() = _accountState.update { it.copy(dialog = null) }
fun deleteAccount() {
viewModelScope.launch(Dispatchers.IO) {
screenModelScope.launch(Dispatchers.IO) {
database.newAccountDao()
.delete(currentAccount!!)

View File

@ -28,6 +28,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.Tab
@ -41,7 +42,6 @@ import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.spacing
import org.koin.androidx.compose.getViewModel
object AccountTab : Tab {
@ -56,7 +56,7 @@ object AccountTab : Tab {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val viewModel = getViewModel<AccountViewModel>()
val viewModel = getScreenModel<AccountScreenModel>()
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
val state by viewModel.accountState.collectAsStateWithLifecycle()

View File

@ -8,10 +8,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.androidx.AndroidScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.compose.home.HomeScreen
import com.readrops.app.compose.util.components.AndroidScreen
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType

View File

@ -16,12 +16,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.androidx.AndroidScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.compose.R
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
import com.readrops.app.compose.home.HomeScreen
import com.readrops.app.compose.util.components.AndroidScreen
import com.readrops.db.entities.account.AccountType
import org.koin.androidx.compose.getViewModel

View File

@ -1,7 +1,7 @@
package com.readrops.app.compose.base
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
@ -15,9 +15,9 @@ import org.koin.core.parameter.parametersOf
/**
* Custom ViewModel for Tab screens handling account change
*/
abstract class TabViewModel(
abstract class TabScreenModel(
private val database: Database,
) : ViewModel(), KoinComponent {
) : ScreenModel, KoinComponent {
/**
* Repository intended to be rebuilt when the current account changes
@ -29,7 +29,7 @@ abstract class TabViewModel(
protected val accountEvent = MutableSharedFlow<Account>()
init {
viewModelScope.launch {
screenModelScope.launch {
database.newAccountDao()
.selectCurrentAccount()
.distinctUntilChanged()

View File

@ -12,8 +12,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.readrops.app.compose.R
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.app.compose.util.toDp
@ -43,6 +45,7 @@ fun FeedItem(
) {
AsyncImage(
model = feed.iconUrl,
error = painterResource(id = R.drawable.ic_rss_feed_grey),
contentDescription = feed.name!!,
modifier = Modifier.size(MaterialTheme.typography.bodyLarge.toDp())
)

View File

@ -1,16 +1,17 @@
package com.readrops.app.compose.feeds
import android.util.Patterns
import androidx.lifecycle.viewModelScope
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.utils.HtmlParser
import com.readrops.app.compose.base.TabViewModel
import com.readrops.app.compose.base.TabScreenModel
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.app.compose.util.components.TextFieldError
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import com.readrops.db.filters.MainFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@ -24,11 +25,11 @@ import org.koin.core.component.get
import java.net.UnknownHostException
@OptIn(ExperimentalCoroutinesApi::class)
class FeedViewModel(
class FeedScreenModel(
database: Database,
private val getFoldersWithFeeds: GetFoldersWithFeeds,
private val localRSSDataSource: LocalRSSDataSource,
) : TabViewModel(database), KoinComponent {
) : TabScreenModel(database), KoinComponent {
private val _feedState = MutableStateFlow(FeedState())
val feedsState = _feedState.asStateFlow()
@ -43,10 +44,10 @@ class FeedViewModel(
val folderState = _folderState.asStateFlow()
init {
viewModelScope.launch(context = Dispatchers.IO) {
screenModelScope.launch(context = Dispatchers.IO) {
accountEvent
.flatMapConcat { account ->
getFoldersWithFeeds.get(account.id)
getFoldersWithFeeds.get(account.id, MainFilter.ALL)
}
.catch { throwable ->
_feedState.update {
@ -60,7 +61,7 @@ class FeedViewModel(
}
}
viewModelScope.launch(context = Dispatchers.IO) {
screenModelScope.launch(context = Dispatchers.IO) {
database.newAccountDao()
.selectAllAccounts()
.collect { accounts ->
@ -73,7 +74,7 @@ class FeedViewModel(
}
}
viewModelScope.launch(context = Dispatchers.IO) {
screenModelScope.launch(context = Dispatchers.IO) {
accountEvent
.flatMapConcat { account ->
database.newFolderDao()
@ -93,7 +94,25 @@ class FeedViewModel(
fun setFolderExpandState(isExpanded: Boolean) =
_feedState.update { it.copy(areFoldersExpanded = isExpanded) }
fun closeDialog() = _feedState.update { it.copy(dialog = null) }
fun closeDialog(dialog: DialogState? = null) {
if (dialog is DialogState.AddFeed) {
_addFeedDialogState.update {
it.copy(
url = "",
error = null,
)
}
} else if (dialog is DialogState.AddFolder || dialog is DialogState.UpdateFolder) {
_folderState.update {
it.copy(
folder = Folder(),
nameError = null,
)
}
}
_feedState.update { it.copy(dialog = null) }
}
fun openDialog(state: DialogState) {
if (state is DialogState.UpdateFeed) {
@ -119,13 +138,13 @@ class FeedViewModel(
}
fun deleteFeed(feed: Feed) {
viewModelScope.launch(Dispatchers.IO) {
screenModelScope.launch(Dispatchers.IO) {
repository?.deleteFeed(feed)
}
}
fun deleteFolder(folder: Folder) {
viewModelScope.launch(Dispatchers.IO) {
screenModelScope.launch(Dispatchers.IO) {
repository?.deleteFolder(folder)
}
}
@ -165,13 +184,13 @@ class FeedViewModel(
return
}
else -> viewModelScope.launch(Dispatchers.IO) {
else -> screenModelScope.launch(Dispatchers.IO) {
try {
if (localRSSDataSource.isUrlRSSResource(url)) {
// TODO add support for all account types
repository?.insertNewFeeds(listOf(url))
closeDialog()
closeDialog(DialogState.AddFeed)
} else {
val rssUrls = HtmlParser.getFeedLink(url, get())
@ -183,7 +202,7 @@ class FeedViewModel(
// TODO add support for all account types
repository?.insertNewFeeds(rssUrls.map { it.url })
closeDialog()
closeDialog(DialogState.AddFeed)
}
}
} catch (e: Exception) {
@ -196,15 +215,6 @@ class FeedViewModel(
}
}
fun resetAddFeedDialogState() {
_addFeedDialogState.update {
it.copy(
url = "",
error = null,
)
}
}
// add feed
// update feed
@ -266,7 +276,7 @@ class FeedViewModel(
}
else -> {
viewModelScope.launch(Dispatchers.IO) {
screenModelScope.launch(Dispatchers.IO) {
with(_updateFeedDialogState.value) {
repository?.updateFeed(
Feed(
@ -295,13 +305,6 @@ class FeedViewModel(
)
}
fun resetFolderState() = _folderState.update {
it.copy(
folder = Folder(),
nameError = null,
)
}
fun folderValidate(updateFolder: Boolean = false) {
val name = _folderState.value.name.orEmpty()
@ -311,7 +314,7 @@ class FeedViewModel(
return
}
viewModelScope.launch(Dispatchers.IO) {
screenModelScope.launch(Dispatchers.IO) {
if (updateFolder) {
repository?.updateFolder(_folderState.value.folder)
} else {
@ -320,8 +323,7 @@ class FeedViewModel(
})
}
closeDialog()
resetFolderState()
closeDialog(DialogState.AddFolder)
}
}

View File

@ -30,6 +30,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.readrops.app.compose.R
@ -43,7 +44,6 @@ import com.readrops.app.compose.util.components.Placeholder
import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Feed
import org.koin.androidx.compose.getViewModel
object FeedTab : Tab {
@ -59,7 +59,8 @@ object FeedTab : Tab {
override fun Content() {
val haptic = LocalHapticFeedback.current
val uriHandler = LocalUriHandler.current
val viewModel = getViewModel<FeedViewModel>()
val viewModel = getScreenModel<FeedScreenModel>()
val state by viewModel.feedsState.collectAsStateWithLifecycle()
@ -68,8 +69,7 @@ object FeedTab : Tab {
AddFeedDialog(
viewModel = viewModel,
onDismiss = {
viewModel.closeDialog()
viewModel.resetAddFeedDialogState()
viewModel.closeDialog(DialogState.AddFeed)
},
)
}
@ -122,8 +122,7 @@ object FeedTab : Tab {
FolderDialog(
viewModel = viewModel,
onDismiss = {
viewModel.closeDialog()
viewModel.resetFolderState()
viewModel.closeDialog(DialogState.AddFolder)
},
onValidate = {
viewModel.folderValidate()
@ -151,8 +150,7 @@ object FeedTab : Tab {
updateFolder = true,
viewModel = viewModel,
onDismiss = {
viewModel.closeDialog()
viewModel.resetFolderState()
viewModel.closeDialog(DialogState.UpdateFolder(dialog.folder))
},
onValidate = {
viewModel.folderValidate(updateFolder = true)

View File

@ -21,7 +21,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.feeds.FeedScreenModel
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
@ -29,7 +29,7 @@ import com.readrops.app.compose.util.theme.ShortSpacer
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddFeedDialog(
viewModel: FeedViewModel,
viewModel: FeedScreenModel,
onDismiss: () -> Unit,
) {
val state by viewModel.addFeedDialogState.collectAsStateWithLifecycle()

View File

@ -13,14 +13,14 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.feeds.FeedScreenModel
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.LargeSpacer
@Composable
fun FolderDialog(
updateFolder: Boolean = false,
viewModel: FeedViewModel,
viewModel: FeedScreenModel,
onDismiss: () -> Unit,
onValidate: () -> Unit
) {

View File

@ -15,7 +15,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.feeds.FeedScreenModel
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
@ -24,7 +24,7 @@ import com.readrops.app.compose.util.theme.MediumSpacer
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateFeedDialog(
viewModel: FeedViewModel,
viewModel: FeedScreenModel,
onDismissRequest: () -> Unit
) {
val state by viewModel.updateFeedDialogState.collectAsStateWithLifecycle()

View File

@ -16,7 +16,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.androidx.AndroidScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.CurrentTab
@ -26,6 +25,7 @@ import com.readrops.app.compose.account.AccountTab
import com.readrops.app.compose.feeds.FeedTab
import com.readrops.app.compose.more.MoreTab
import com.readrops.app.compose.timelime.TimelineTab
import com.readrops.app.compose.util.components.AndroidScreen
class HomeScreen : AndroidScreen() {

View File

@ -2,7 +2,7 @@ package com.readrops.app.compose.item
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.androidx.AndroidScreen
import com.readrops.app.compose.util.components.AndroidScreen
class ItemScreen : AndroidScreen() {

View File

@ -7,9 +7,7 @@ import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
data class ErrorResult(
val values: Map<Feed, Exception>
)
typealias ErrorResult = Map<Feed, Exception>
abstract class ARepository(
val database: Database,
@ -29,7 +27,7 @@ abstract class ARepository(
* and errors per feed if occurred to be transmitted to the user
*/
abstract suspend fun synchronize(
selectedFeeds: List<Feed>?,
selectedFeeds: List<Feed>,
onUpdate: (Feed) -> Unit
): Pair<SyncResult, ErrorResult>
@ -73,6 +71,10 @@ abstract class BaseRepository(
database.newItemDao().setAllStarredItemsRead(accountId)
}
open suspend fun setAllNewItemsRead(accountId: Int) {
database.newItemDao().setAllNewItemsRead(accountId)
}
open suspend fun setAllItemsReadByFeed(feedId: Int, accountId: Int) {
database.newItemDao().setAllItemsReadByFeed(feedId, accountId)
}

View File

@ -3,6 +3,8 @@ package com.readrops.app.compose.repositories
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.filters.MainFilter
import com.readrops.db.queries.FoldersAndFeedsQueriesBuilder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -10,12 +12,17 @@ class GetFoldersWithFeeds(
private val database: Database,
) {
fun get(accountId: Int): Flow<Map<Folder?, List<Feed>>> {
fun get(accountId: Int, mainFilter: MainFilter): Flow<Map<Folder?, List<Feed>>> {
val foldersAndFeedsQuery =
FoldersAndFeedsQueriesBuilder.buildFoldersAndFeedsQuery(accountId, mainFilter)
val feedsWithoutFolderQuery =
FoldersAndFeedsQueriesBuilder.buildFeedsWithoutFolderQuery(accountId, mainFilter)
return combine(
flow = database.newFolderDao()
.selectFoldersAndFeeds(accountId),
.selectFoldersAndFeeds(foldersAndFeedsQuery),
flow2 = database.newFeedDao()
.selectFeedsWithoutFolder(accountId)
.selectFeedsWithoutFolder(feedsWithoutFolderQuery)
) { folders, feedsWithoutFolder ->
val foldersWithFeeds = folders.groupBy(
keySelector = {
@ -47,7 +54,16 @@ class GetFoldersWithFeeds(
foldersWithFeeds + mapOf(
Pair(
null,
feedsWithoutFolder.map { it.feed.apply { unreadCount = it.unreadCount } })
feedsWithoutFolder.map { feedWithoutFolder ->
Feed(
id = feedWithoutFolder.feedId,
name = feedWithoutFolder.feedName,
iconUrl = feedWithoutFolder.feedIcon,
url = feedWithoutFolder.feedUrl,
siteUrl = feedWithoutFolder.feedSiteUrl,
unreadCount = feedWithoutFolder.unreadCount
)
})
)
} else {
foldersWithFeeds

View File

@ -27,15 +27,15 @@ class LocalRSSRepository(
}
override suspend fun synchronize(
selectedFeeds: List<Feed>?,
selectedFeeds: List<Feed>,
onUpdate: (Feed) -> Unit
): Pair<SyncResult, ErrorResult> {
val errors = mutableMapOf<Feed, Exception>()
val syncResult = SyncResult()
val feeds = if (selectedFeeds.isNullOrEmpty()) {
val feeds = selectedFeeds.ifEmpty {
database.newFeedDao().selectFeeds(account.id)
} else selectedFeeds
}
for (feed in feeds) {
onUpdate(feed)
@ -61,7 +61,7 @@ class LocalRSSRepository(
}
return Pair(syncResult, ErrorResult(errors))
return Pair(syncResult, errors)
}
override suspend fun synchronize(): SyncResult =

View File

@ -0,0 +1,67 @@
package com.readrops.app.compose.timelime
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.readrops.api.utils.exceptions.HttpException
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.exceptions.UnknownFormatException
import com.readrops.app.compose.R
import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
import java.io.IOException
import java.net.UnknownHostException
@Composable
fun ErrorListDialog(
errorResult: ErrorResult,
onDismiss: () -> Unit,
) {
val scrollableState = rememberScrollState()
BaseDialog(
title = stringResource(R.string.synchronization_errors),
icon = painterResource(id = R.drawable.ic_error),
onDismiss = onDismiss,
modifier = Modifier.heightIn(max = 500.dp)
) {
Text(
text = pluralStringResource(
id = R.plurals.error_occurred_feed,
count = errorResult.size
)
)
MediumSpacer()
Column(
modifier = Modifier.verticalScroll(scrollableState)
) {
for (error in errorResult.entries) {
Text(text = "${error.key.name}: ${errorText(error.value)}")
ShortSpacer()
}
}
}
}
// TODO check compatibility with other accounts errors
@Composable
fun errorText(exception: Exception) = when (exception) {
is HttpException -> stringResource(id = R.string.unreachable_feed_http_error, exception.code.toString())
is UnknownHostException -> stringResource(R.string.unreachable_feed)
is IOException -> stringResource(R.string.network_failure, exception.message.orEmpty())
is ParseException, is UnknownFormatException -> stringResource(R.string.processing_feed_error)
else -> "${exception.javaClass.simpleName}: ${exception.message}"
}

View File

@ -24,7 +24,8 @@ import com.readrops.db.queries.QueryFilters
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterBottomSheet(
viewModel: TimelineViewModel,
onSetShowReadItemsState: () -> Unit,
onSetSortTypeState: () -> Unit,
filters: QueryFilters,
onDismiss: () -> Unit,
) {
@ -44,11 +45,11 @@ fun FilterBottomSheet(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setShowReadItemsState(!filters.showReadItems) }
.clickable(onClick = onSetShowReadItemsState)
) {
Checkbox(
checked = filters.showReadItems,
onCheckedChange = { viewModel.setShowReadItemsState(!filters.showReadItems) }
onCheckedChange = { onSetShowReadItemsState() }
)
ShortSpacer()
@ -60,24 +61,15 @@ fun FilterBottomSheet(
ShortSpacer()
fun setSortTypeState() {
viewModel.setSortTypeState(
if (filters.sortType == ListSortType.NEWEST_TO_OLDEST)
ListSortType.OLDEST_TO_NEWEST
else
ListSortType.NEWEST_TO_OLDEST
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { setSortTypeState() }
.clickable(onClick = onSetSortTypeState)
) {
Checkbox(
checked = filters.sortType == ListSortType.OLDEST_TO_NEWEST,
onCheckedChange = { setSortTypeState() }
onCheckedChange = { onSetSortTypeState() }
)
ShortSpacer()

View File

@ -43,7 +43,6 @@ fun TimelineItem(
itemWithFeed: ItemWithFeed,
onClick: () -> Unit,
onFavorite: () -> Unit,
onReadLater: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
compactLayout: Boolean = false,
@ -75,7 +74,8 @@ fun TimelineItem(
) {
AsyncImage(
model = itemWithFeed.feedIconUrl,
contentDescription = null,
error = painterResource(id = R.drawable.ic_rss_feed_grey),
contentDescription = itemWithFeed.feedName,
placeholder = painterResource(R.drawable.ic_rss_feed_grey),
modifier = Modifier.size(24.dp)
)
@ -188,12 +188,6 @@ fun TimelineItem(
modifier = Modifier.clickable { onFavorite() }
)
Icon(
painter = painterResource(id = R.drawable.ic_read_later),
contentDescription = null,
modifier = Modifier.clickable { onReadLater() }
)
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = null,

View File

@ -3,19 +3,21 @@ package com.readrops.app.compose.timelime
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Immutable
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.readrops.app.compose.base.TabViewModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.app.compose.base.TabScreenModel
import com.readrops.app.compose.repositories.ErrorResult
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.filters.FilterType
import com.readrops.db.filters.ListSortType
import com.readrops.db.filters.MainFilter
import com.readrops.db.filters.SubFilter
import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.queries.ItemsQueryBuilder
import com.readrops.db.queries.QueryFilters
@ -30,11 +32,11 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class TimelineViewModel(
class TimelineScreenModel(
private val database: Database,
private val getFoldersWithFeeds: GetFoldersWithFeeds,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : TabViewModel(database) {
) : TabScreenModel(database) {
private val _timelineState = MutableStateFlow(TimelineState())
val timelineState = _timelineState.asStateFlow()
@ -42,13 +44,12 @@ class TimelineViewModel(
private val filters = MutableStateFlow(_timelineState.value.filters)
init {
viewModelScope.launch(dispatcher) {
screenModelScope.launch(dispatcher) {
combine(
accountEvent,
filters
) { account, filters ->
filters.accountId = account.id
Pair(account, filters)
Pair(account, filters.copy(accountId = account.id))
}.collectLatest { (account, filters) ->
val query = ItemsQueryBuilder.buildItemsQuery(filters)
@ -63,11 +64,11 @@ class TimelineViewModel(
database.newItemDao().selectAll(query)
},
).flow
.cachedIn(viewModelScope)
.cachedIn(screenModelScope)
)
}
getFoldersWithFeeds.get(account.id)
getFoldersWithFeeds.get(account.id, filters.mainFilter)
.collect { foldersAndFeeds ->
_timelineState.update {
it.copy(
@ -80,16 +81,49 @@ class TimelineViewModel(
}
fun refreshTimeline() {
_timelineState.update { it.copy(isRefreshing = true) }
viewModelScope.launch(dispatcher) {
repository?.synchronize(null) {
screenModelScope.launch(dispatcher) {
val selectedFeeds = if (currentAccount!!.isLocal) {
when (filters.value.subFilter) {
SubFilter.FEED -> listOf(
database.newFeedDao().selectFeed(filters.value.filterFeedId)
)
SubFilter.FOLDER -> database.newFeedDao()
.selectFeedsByFolder(filters.value.filterFolderId)
else -> listOf()
}
} else listOf()
_timelineState.update {
it.copy(
feedCount = 0,
feedMax = if (selectedFeeds.isNotEmpty())
selectedFeeds.size
else
database.newFeedDao().selectFeedCount(currentAccount!!.id)
)
}
_timelineState.update { it.copy(isRefreshing = true) }
val results = repository?.synchronize(
selectedFeeds = selectedFeeds,
onUpdate = { feed ->
_timelineState.update {
it.copy(
currentFeed = feed.name!!,
feedCount = it.feedCount + 1
)
}
}
)
_timelineState.update {
it.copy(
isRefreshing = false,
endSynchronizing = true
endSynchronizing = true,
synchronizationErrors = if (results!!.second.isNotEmpty()) results.second else null
)
}
}
@ -103,12 +137,15 @@ class TimelineViewModel(
_timelineState.update { it.copy(isDrawerOpen = false) }
}
fun updateDrawerDefaultItem(selection: FilterType) {
fun updateDrawerDefaultItem(selection: MainFilter) {
_timelineState.update {
it.copy(
filters = updateFilters {
it.filters.copy(
filterType = selection
mainFilter = selection,
subFilter = SubFilter.ALL,
filterFeedId = 0,
filterFolderId = 0
)
},
isDrawerOpen = false
@ -121,7 +158,7 @@ class TimelineViewModel(
it.copy(
filters = updateFilters {
it.filters.copy(
filterType = FilterType.FOLDER_FILER,
subFilter = SubFilter.FOLDER,
filterFolderId = folder.id,
filterFeedId = 0
)
@ -137,7 +174,7 @@ class TimelineViewModel(
it.copy(
filters = updateFilters {
it.filters.copy(
filterType = FilterType.FEED_FILTER,
subFilter = SubFilter.FEED,
filterFeedId = feed.id,
filterFolderId = 0
)
@ -161,13 +198,13 @@ class TimelineViewModel(
}
private fun updateItemReadState(item: Item) {
viewModelScope.launch(dispatcher) {
screenModelScope.launch(dispatcher) {
repository?.setItemReadState(item)
}
}
fun updateStarState(item: Item) {
viewModelScope.launch(dispatcher) {
screenModelScope.launch(dispatcher) {
with(item) {
isStarred = isStarred.not()
repository?.setItemStarState(this)
@ -186,30 +223,39 @@ class TimelineViewModel(
}
fun setAllItemsRead() {
viewModelScope.launch(dispatcher) {
when (_timelineState.value.filters.filterType) {
FilterType.FEED_FILTER ->
screenModelScope.launch(dispatcher) {
val accountId = currentAccount!!.id
when (_timelineState.value.filters.subFilter) {
SubFilter.FEED ->
repository?.setAllItemsReadByFeed(
_timelineState.value.filters.filterFeedId,
currentAccount!!.id
feedId = _timelineState.value.filters.filterFeedId,
accountId = accountId
)
FilterType.FOLDER_FILER -> repository?.setAllItemsReadByFolder(
_timelineState.value.filters.filterFolderId,
currentAccount!!.id
SubFilter.FOLDER -> repository?.setAllItemsReadByFolder(
folderId = _timelineState.value.filters.filterFolderId,
accountId = accountId
)
FilterType.READ_IT_LATER_FILTER -> TODO()
FilterType.STARS_FILTER -> repository?.setAllStarredItemsRead(currentAccount!!.id)
FilterType.NO_FILTER -> repository?.setAllItemsRead(currentAccount!!.id)
FilterType.NEW -> TODO()
else -> when (_timelineState.value.filters.mainFilter) {
MainFilter.STARS -> repository?.setAllStarredItemsRead(accountId)
MainFilter.ALL -> repository?.setAllItemsRead(accountId)
MainFilter.NEW -> repository?.setAllNewItemsRead(accountId)
}
}
}
}
fun openDialog(dialog: DialogState) = _timelineState.update { it.copy(dialog = dialog) }
fun closeDialog() = _timelineState.update { it.copy(dialog = null) }
fun closeDialog(dialog: DialogState? = null) {
if (dialog is DialogState.ErrorList) {
_timelineState.update { it.copy(synchronizationErrors = null) }
}
_timelineState.update { it.copy(dialog = null) }
}
fun setShowReadItemsState(showReadItems: Boolean) {
_timelineState.update {
@ -234,22 +280,32 @@ class TimelineViewModel(
)
}
}
}
@Immutable
data class TimelineState(
val isRefreshing: Boolean = false,
val isDrawerOpen: Boolean = false,
val currentFeed: String = "",
val feedCount: Int = 0,
val feedMax: Int = 0,
val endSynchronizing: Boolean = false,
val synchronizationErrors: ErrorResult? = null,
val filters: QueryFilters = QueryFilters(),
val filterFeedName: String = "",
val filterFolderName: String = "",
val foldersAndFeeds: Map<Folder?, List<Feed>> = emptyMap(),
val itemState: Flow<PagingData<ItemWithFeed>> = emptyFlow(),
val dialog: DialogState? = null
)
) {
val showSubtitle = filters.subFilter != SubFilter.ALL
}
sealed interface DialogState {
object ConfirmDialog : DialogState
object FilterSheet : DialogState
class ErrorList(val errorResult: ErrorResult) : DialogState
}

View File

@ -1,15 +1,16 @@
package com.readrops.app.compose.timelime
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
@ -18,34 +19,49 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.readrops.app.compose.R
import com.readrops.app.compose.item.ItemScreen
import com.readrops.app.compose.timelime.drawer.TimelineDrawer
import com.readrops.app.compose.util.components.CenteredColumn
import com.readrops.app.compose.util.components.CenteredProgressIndicator
import com.readrops.app.compose.util.components.Placeholder
import com.readrops.app.compose.util.components.RefreshScreen
import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.filters.FilterType
import org.koin.androidx.compose.getViewModel
import com.readrops.db.filters.ListSortType
import com.readrops.db.filters.MainFilter
import com.readrops.db.filters.SubFilter
import kotlinx.coroutines.launch
object TimelineTab : Tab {
@ -60,17 +76,34 @@ object TimelineTab : Tab {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val viewModel = getViewModel<TimelineViewModel>()
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val viewModel = getScreenModel<TimelineScreenModel>()
val state by viewModel.timelineState.collectAsStateWithLifecycle()
val items = state.itemState.collectAsLazyPagingItems()
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val lazyListState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val snackbarHostState = remember { SnackbarHostState() }
val scrollState = rememberLazyListState()
LaunchedEffect(state.isRefreshing) {
if (state.isRefreshing) {
pullToRefreshState.startRefresh()
} else {
pullToRefreshState.endRefresh()
}
}
// Material3 pull to refresh doesn't have a onRefresh callback,
// so we need to listen to the internal state change to trigger the refresh
LaunchedEffect(pullToRefreshState.isRefreshing) {
if (pullToRefreshState.isRefreshing && !state.isRefreshing) {
viewModel.refreshTimeline()
}
}
// Use the depreciated refresh swipe as the material 3 one isn't available yet
val swipeState = rememberSwipeRefreshState(state.isRefreshing)
val drawerState = rememberDrawerState(
initialValue = DrawerValue.Closed,
confirmStateChange = {
@ -97,8 +130,27 @@ object TimelineTab : Tab {
}
}
when (state.dialog) {
DialogState.ConfirmDialog -> {
LaunchedEffect(state.synchronizationErrors) {
if (state.synchronizationErrors != null) {
coroutineScope.launch {
val action = snackbarHostState.showSnackbar(
message = context.resources.getQuantityString(R.plurals.error_occurred, state.synchronizationErrors!!.size),
actionLabel = context.getString(R.string.details),
duration = SnackbarDuration.Short
)
if (action == SnackbarResult.ActionPerformed) {
viewModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
} else {
// remove errors from state
viewModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
}
}
}
}
when (val dialog = state.dialog) {
is DialogState.ConfirmDialog -> {
TwoChoicesDialog(
title = "Mark all items as read",
text = "Do you really want to mark all items as read?",
@ -113,13 +165,28 @@ object TimelineTab : Tab {
)
}
DialogState.FilterSheet -> {
is DialogState.FilterSheet -> {
FilterBottomSheet(
viewModel = viewModel,
filters = state.filters,
onDismiss = {
viewModel.closeDialog()
}
onSetShowReadItemsState = {
viewModel.setShowReadItemsState(!state.filters.showReadItems)
},
onSetSortTypeState = {
viewModel.setSortTypeState(
if (state.filters.sortType == ListSortType.NEWEST_TO_OLDEST)
ListSortType.OLDEST_TO_NEWEST
else
ListSortType.NEWEST_TO_OLDEST
)
},
onDismiss = { viewModel.closeDialog() }
)
}
is DialogState.ErrorList -> {
ErrorListDialog(
errorResult = dialog.errorResult,
onDismiss = { viewModel.closeDialog(state.dialog) }
)
}
@ -147,16 +214,30 @@ object TimelineTab : Tab {
topBar = {
TopAppBar(
title = {
Text(
text = when (state.filters.filterType) {
FilterType.FEED_FILTER -> state.filterFeedName
FilterType.FOLDER_FILER -> state.filterFolderName
FilterType.READ_IT_LATER_FILTER -> stringResource(R.string.read_later)
FilterType.STARS_FILTER -> stringResource(R.string.favorites)
FilterType.NO_FILTER -> stringResource(R.string.articles)
FilterType.NEW -> stringResource(R.string.new_articles)
Column {
Text(
text = when (state.filters.mainFilter) {
MainFilter.STARS -> stringResource(R.string.favorites)
MainFilter.ALL -> stringResource(R.string.articles)
MainFilter.NEW -> stringResource(R.string.new_articles)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (state.showSubtitle) {
Text(
text = when (state.filters.subFilter) {
SubFilter.FEED -> state.filterFeedName
SubFilter.FOLDER -> state.filterFolderName
else -> ""
},
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
)
}
},
navigationIcon = {
IconButton(
@ -189,10 +270,11 @@ object TimelineTab : Tab {
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
onClick = {
if (state.filters.filterType == FilterType.NO_FILTER) {
if (state.filters.mainFilter == MainFilter.ALL) {
viewModel.openDialog(DialogState.ConfirmDialog)
} else {
viewModel.setAllItemsRead()
@ -206,47 +288,78 @@ object TimelineTab : Tab {
}
},
) { paddingValues ->
SwipeRefresh(
state = swipeState,
onRefresh = { viewModel.refreshTimeline() },
modifier = Modifier.padding(paddingValues)
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
when {
state.isRefreshing -> RefreshScreen(
currentFeed = state.currentFeed,
feedCount = state.feedCount,
feedMax = state.feedMax
)
items.isLoading() -> {
Log.d("TAG", "loading")
CenteredColumn {
CircularProgressIndicator()
}
CenteredProgressIndicator()
}
items.isError() -> Text(text = "error")
else -> {
LazyColumn(
state = scrollState,
contentPadding = PaddingValues(vertical = MaterialTheme.spacing.shortSpacing),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.shortSpacing)
) {
items(
count = items.itemCount,
//key = { items[it]!! },
contentType = { "item_with_feed" }
) { itemCount ->
val itemWithFeed = items[itemCount]!!
items.isError() -> {
Placeholder(
text = stringResource(R.string.error_occured),
painter = painterResource(id = R.drawable.ic_error)
)
}
TimelineItem(
itemWithFeed = itemWithFeed,
onClick = {
viewModel.setItemRead(itemWithFeed.item)
navigator.push(ItemScreen())
},
onFavorite = { viewModel.updateStarState(itemWithFeed.item) },
onReadLater = {},
onShare = {
viewModel.shareItem(itemWithFeed.item, context)
},
compactLayout = true
)
else -> {
if (items.itemCount > 0) {
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(vertical = MaterialTheme.spacing.shortSpacing),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.shortSpacing)
) {
items(
count = items.itemCount,
key = items.itemKey { it.item.id },
) { itemCount ->
val itemWithFeed = items[itemCount]!!
TimelineItem(
itemWithFeed = itemWithFeed,
onClick = {
viewModel.setItemRead(itemWithFeed.item)
navigator.push(ItemScreen())
},
onFavorite = { viewModel.updateStarState(itemWithFeed.item) },
onShare = {
viewModel.shareItem(itemWithFeed.item, context)
},
compactLayout = true
)
}
}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
} else {
// Empty lazyColumn to let the pull to refresh be usable
// when the no item placeholder is displayed
LazyColumn(
modifier = Modifier.fillMaxSize()
) {}
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
Placeholder(
text = stringResource(R.string.no_item),
painter = painterResource(R.drawable.ic_rss_feed_grey)
)
}
}
}

View File

@ -51,7 +51,7 @@ fun DrawerFolderItem(
) {
val colors = NavigationDrawerItemDefaults.colors()
var isExpanded by remember { mutableStateOf(false) }
var isExpanded by remember { mutableStateOf(feeds.any { it.id == selectedFeed }) }
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "drawer item arrow rotation"
@ -125,6 +125,7 @@ fun DrawerFolderItem(
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name,
error = painterResource(id = R.drawable.ic_rss_feed_grey),
placeholder = painterResource(id = R.drawable.ic_folder_grey),
modifier = Modifier.size(24.dp)
)

View File

@ -28,12 +28,12 @@ import com.readrops.app.compose.timelime.TimelineState
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.filters.FilterType
import com.readrops.db.filters.MainFilter
@Composable
fun TimelineDrawer(
state: TimelineState,
onClickDefaultItem: (FilterType) -> Unit,
onClickDefaultItem: (MainFilter) -> Unit,
onFolderClick: (Folder) -> Unit,
onFeedClick: (Feed) -> Unit,
) {
@ -47,7 +47,7 @@ fun TimelineDrawer(
Spacer(modifier = Modifier.size(MaterialTheme.spacing.drawerSpacing))
DrawerDefaultItems(
selectedItem = state.filters.filterType,
selectedItem = state.filters.mainFilter,
onClick = { onClickDefaultItem(it) }
)
@ -98,6 +98,7 @@ fun TimelineDrawer(
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name,
error = painterResource(id = R.drawable.ic_rss_feed_grey),
placeholder = painterResource(id = R.drawable.ic_folder_grey),
modifier = Modifier.size(24.dp)
)
@ -116,8 +117,8 @@ fun TimelineDrawer(
@Composable
fun DrawerDefaultItems(
selectedItem: FilterType,
onClick: (FilterType) -> Unit,
selectedItem: MainFilter,
onClick: (MainFilter) -> Unit,
) {
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.articles)) },
@ -127,8 +128,8 @@ fun DrawerDefaultItems(
contentDescription = null
)
},
selected = selectedItem == FilterType.NO_FILTER,
onClick = { onClick(FilterType.NO_FILTER) },
selected = selectedItem == MainFilter.ALL,
onClick = { onClick(MainFilter.ALL) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
@ -140,8 +141,8 @@ fun DrawerDefaultItems(
contentDescription = null
)
},
selected = selectedItem == FilterType.NEW,
onClick = { onClick(FilterType.NEW) },
selected = selectedItem == MainFilter.NEW,
onClick = { onClick(MainFilter.NEW) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
@ -153,21 +154,8 @@ fun DrawerDefaultItems(
contentDescription = null
)
},
selected = selectedItem == FilterType.STARS_FILTER,
onClick = { onClick(FilterType.STARS_FILTER) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.read_later)) },
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_read_later),
contentDescription = null
)
},
selected = selectedItem == FilterType.READ_IT_LATER_FILTER,
onClick = { onClick(FilterType.READ_IT_LATER_FILTER) },
selected = selectedItem == MainFilter.STARS,
onClick = { onClick(MainFilter.STARS) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}

View File

@ -0,0 +1,10 @@
package com.readrops.app.compose.util.components
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.core.screen.uniqueScreenKey
abstract class AndroidScreen : Screen {
override val key: ScreenKey = uniqueScreenKey
}

View File

@ -25,13 +25,15 @@ fun BaseDialog(
title: String,
icon: Painter,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Dialog(
onDismissRequest = onDismiss
) {
Card(
shape = RoundedCornerShape(24.dp)
shape = RoundedCornerShape(24.dp),
modifier = modifier
) {
Column(
verticalArrangement = Arrangement.Center,

View File

@ -0,0 +1,25 @@
package com.readrops.app.compose.util.components
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import com.readrops.app.compose.util.theme.VeryShortSpacer
@Composable
fun RefreshScreen(
currentFeed: String,
feedCount: Int,
feedMax: Int
) {
CenteredColumn {
LinearProgressIndicator(
progress = { feedCount.toFloat() / feedMax.toFloat() }
)
VeryShortSpacer()
Text(
text = "$currentFeed ($feedCount/$feedMax)"
)
}
}

View File

@ -142,5 +142,18 @@
<string name="hide_feeds">Cacher les flux sans nouveaux items</string>
<string name="mark_items_read">Marquer les items comme lus pendant le défilement</string>
<string name="filters">Filtres</string>
<plurals name="error_occurred">
<item quantity="one">Une erreur s\'est produite</item>
<item quantity="other">Des erreurs se sont produites</item>
</plurals>
<string name="details">Détails</string>
<string name="synchronization_errors">Erreurs de synchronisation</string>
<plurals name="error_occurred_feed">
<item quantity="one">Une erreur s\'est produite pour le flux suivant :</item>
<item quantity="other">Des erreurs se sont produites pour les flux suivants :</item>
</plurals>
<string name="unreachable_feed_http_error">Flux non attaignable, erreur HTTP %1$s</string>
<string name="network_failure">Erreur réseau: %1$s</string>
<string name="processing_feed_error">Erreur de traitement du flux</string>
<string name="unreachable_feed">Flux non attaignable</string>
</resources>

View File

@ -148,4 +148,18 @@
<string name="mark_items_read">Mark items read on scroll</string>
<string name="new_articles">New articles</string>
<string name="filters">Filters</string>
<plurals name="error_occurred">
<item quantity="one">An error occurred</item>
<item quantity="other">Some errors occurred</item>
</plurals>
<string name="details">Details</string>
<string name="synchronization_errors">Synchronization errors</string>
<plurals name="error_occurred_feed">
<item quantity="one">An error occurred for the following feed:</item>
<item quantity="other">Some errors occurred for the following feeds:</item>
</plurals>
<string name="unreachable_feed_http_error">Unreachable feed, HTTP error %1$s</string>
<string name="network_failure">Network failure: %1$s</string>
<string name="processing_feed_error">Processing feed error</string>
<string name="unreachable_feed">Unreachable feed</string>
</resources>

View File

@ -76,13 +76,9 @@ dependencies {
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
def room_version = "2.4.3"
api "androidx.room:room-runtime:$room_version"
api "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-rxjava2:$room_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
implementation "androidx.room:room-paging:$room_version"
implementation(libs.bundles.room)
kapt(libs.room.compiler)
androidTestImplementation(libs.room.testing)
implementation 'com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4'
kapt 'com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4'
@ -92,10 +88,8 @@ dependencies {
api 'joda-time:joda-time:2.10.10'
api "io.insert-koin:koin-core:$rootProject.ext.koin_version"
api "io.insert-koin:koin-android:$rootProject.ext.koin_version"
api "io.insert-koin:koin-androidx-compose:3.4.2"
api "io.insert-koin:koin-android-compat:$rootProject.ext.koin_version"
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'

View File

@ -4,8 +4,9 @@ import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.readrops.db.filters.FilterType
import com.readrops.db.filters.ListSortType
import com.readrops.db.filters.MainFilter
import com.readrops.db.filters.SubFilter
import com.readrops.db.queries.ItemsQueryBuilder
import com.readrops.db.queries.QueryFilters
import junit.framework.TestCase.assertFalse
@ -51,7 +52,7 @@ class ItemsQueryBuilderTest {
@Test
fun feedFilterCaseTest() {
val queryFilters = QueryFilters(accountId = 1, filterType = FilterType.FEED_FILTER,
val queryFilters = QueryFilters(accountId = 1, subFilter = SubFilter.FEED,
filterFeedId = 15)
val query = ItemsQueryBuilder.buildItemsQuery(queryFilters)
@ -60,19 +61,9 @@ class ItemsQueryBuilderTest {
assertTrue(query.sql.contains("feed_id = 15 And read_it_later = 0"))
}
@Test
fun readLaterFilterCaseTest() {
val queryFilters = QueryFilters(accountId = 1, filterType = FilterType.READ_IT_LATER_FILTER)
val query = ItemsQueryBuilder.buildItemsQuery(queryFilters)
database.query(query)
assertTrue(query.sql.contains("read_it_later = 1"))
}
@Test
fun starsFilterCaseTest() {
val queryFilters = QueryFilters(accountId = 1, filterType = FilterType.STARS_FILTER)
val queryFilters = QueryFilters(accountId = 1, mainFilter = MainFilter.STARS)
val query = ItemsQueryBuilder.buildItemsQuery(queryFilters)
database.query(query)
@ -82,7 +73,7 @@ class ItemsQueryBuilderTest {
@Test
fun folderFilterCaseTest() {
val queryFilters = QueryFilters(accountId = 1, filterType = FilterType.FOLDER_FILER, filterFolderId = 1)
val queryFilters = QueryFilters(accountId = 1, subFilter = SubFilter.FOLDER, filterFolderId = 1)
val query = ItemsQueryBuilder.buildItemsQuery(queryFilters)
database.query(query)
@ -107,7 +98,7 @@ class ItemsQueryBuilderTest {
@Test
fun separateStateTest() {
val queryFilters = QueryFilters(accountId = 1, showReadItems = false, filterType = FilterType.STARS_FILTER)
val queryFilters = QueryFilters(accountId = 1, showReadItems = false, mainFilter = MainFilter.STARS)
val query = ItemsQueryBuilder.buildItemsQuery(queryFilters, true)
database.query(query)
@ -127,7 +118,7 @@ class ItemsQueryBuilderTest {
@Test(expected = IllegalArgumentException::class)
fun filterFeedIdExceptionTest() {
val queryFilters = QueryFilters(accountId = 1, filterType = FilterType.FEED_FILTER)
val queryFilters = QueryFilters(accountId = 1, subFilter = SubFilter.FEED)
ItemsQueryBuilder.buildItemsQuery(queryFilters)
}
}

View File

@ -17,17 +17,17 @@ interface BaseDao<T> {
fun compatInsert(entity: T): Long
@Insert
fun insert(entities: List<T>?): List<Long>
fun insert(entities: List<T>): List<Long>
@Update
fun update(entity: T): Completable
@Update
fun update(entities: List<T>?): Completable
fun update(entities: List<T>): Completable
@Delete
fun delete(entity: T): Completable
@Delete
fun delete(entities: List<T>?): Completable
fun delete(entities: List<T>): Completable
}

View File

@ -10,17 +10,17 @@ interface NewBaseDao<T> {
suspend fun insert(entity: T): Long
@Insert
suspend fun insert(entities: List<T>?): List<Long>
suspend fun insert(entities: List<T>): List<Long>
@Update
suspend fun update(entity: T)
@Update
suspend fun update(entities: List<T>?)
suspend fun update(entities: List<T>)
@Delete
suspend fun delete(entity: T)
@Delete
suspend fun delete(entities: List<T>?)
suspend fun delete(entities: List<T>)
}

View File

@ -2,15 +2,21 @@ package com.readrops.db.dao.newdao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RawQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import com.readrops.db.pojo.FeedWithCount
import kotlinx.coroutines.flow.Flow
@Dao
abstract class NewFeedDao : NewBaseDao<Feed> {
@Query("Select * From Feed")
abstract fun selectFeeds(): Flow<List<Feed>>
@Query("Select * From Feed Where id = :feedId")
abstract suspend fun selectFeed(feedId: Int): Feed
@Query("Select * From Feed Where folder_id = :folderId")
abstract suspend fun selectFeedsByFolder(folderId: Int): List<Feed>
@Query("Select * from Feed Where account_id = :accountId order by name ASC")
abstract suspend fun selectFeeds(accountId: Int): List<Feed>
@ -21,11 +27,12 @@ abstract class NewFeedDao : NewBaseDao<Feed> {
@Query("Select case When :feedUrl In (Select url from Feed Where account_id = :accountId) Then 1 else 0 end")
abstract suspend fun feedExists(feedUrl: String, accountId: Int): Boolean
@Query("Select Feed.*, count(*) as unreadCount From Feed Left Join Item On Feed.id = Item.feed_id " +
"Where Feed.folder_id is Null And (Item.read = 0 OR Item.read is NULL) And Feed.account_id = :accountId Group by Feed.id")
abstract fun selectFeedsWithoutFolder(accountId: Int): Flow<List<FeedWithCount>>
@RawQuery(observedEntities = [Feed::class, Item::class])
abstract fun selectFeedsWithoutFolder(query: SupportSQLiteQuery): Flow<List<FeedWithCount>>
@Query("Update Feed set name = :feedName, url = :feedUrl, folder_id = :folderId Where id = :feedId")
abstract fun updateFeedFields(feedId: Int, feedName: String, feedUrl: String, folderId: Int?)
@Query("Select count(*) from Feed Where account_id = :accountId")
abstract suspend fun selectFeedCount(accountId: Int): Int
}

View File

@ -2,20 +2,20 @@ package com.readrops.db.dao.newdao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.RawQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.pojo.FolderWithFeed
import kotlinx.coroutines.flow.Flow
@Dao
abstract class NewFolderDao : NewBaseDao<Folder> {
interface NewFolderDao : NewBaseDao<Folder> {
@Query("Select Folder.id As folderId, Folder.name as folderName, Feed.id As feedId, Feed.name AS feedName, " +
"Feed.icon_url As feedIcon, Feed.url as feedUrl, Feed.siteUrl as feedSiteUrl, count(*) As unreadCount, Folder.account_id as accountId " +
"From Folder Left Join Feed On Folder.id = Feed.folder_id Left Join Item On Item.feed_id = Feed.id " +
"Where Feed.folder_id is NULL OR Feed.folder_id is NOT NULL And (Item.read = 0 OR Item.read is NULL) " +
"And Feed.account_id = :accountId Group By Feed.id, Folder.id Order By Folder.id")
abstract fun selectFoldersAndFeeds(accountId: Int): Flow<List<FolderWithFeed>>
@RawQuery(observedEntities = [Folder::class, Feed::class, Item::class])
fun selectFoldersAndFeeds(query: SupportSQLiteQuery): Flow<List<FolderWithFeed>>
@Query("Select * From Folder Where account_id = :accountId")
abstract fun selectFolders(accountId: Int): Flow<List<Folder>>
fun selectFolders(accountId: Int): Flow<List<Folder>>
}

View File

@ -29,6 +29,11 @@ abstract class NewItemDao : NewBaseDao<Item> {
@Query("Update Item set read = 1 Where starred = 1 And feed_id IN (Select id From Feed Where account_id = :accountId)")
abstract suspend fun setAllStarredItemsRead(accountId: Int)
@Query("Update Item set read = 1 Where DateTime(Round(pub_date / 1000), 'unixepoch') " +
"Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\") " +
"And feed_id IN (Select id From Feed Where account_id = :accountId)")
abstract suspend fun setAllNewItemsRead(accountId: Int)
@Query("Update Item set read = 1 Where feed_id IN " +
"(Select id From Feed Where id = :feedId And account_id = :accountId)")
abstract suspend fun setAllItemsReadByFeed(feedId: Int, accountId: Int)

View File

@ -1,10 +0,0 @@
package com.readrops.db.filters;
enum class FilterType {
FEED_FILTER,
FOLDER_FILER,
READ_IT_LATER_FILTER,
STARS_FILTER,
NO_FILTER,
NEW
}

View File

@ -0,0 +1,13 @@
package com.readrops.db.filters
enum class MainFilter {
STARS,
NEW,
ALL
}
enum class SubFilter {
FEED,
FOLDER,
ALL
}

View File

@ -25,6 +25,11 @@ data class FolderWithFeed(
)
data class FeedWithCount(
@Embedded val feed: Feed,
val unreadCount: Int
val feedId: Int = 0,
val feedName: String? = null,
val feedIcon: String? = null,
val feedUrl: String? = null,
val feedSiteUrl: String? = null,
val unreadCount: Int = 0,
val accountId: Int = 0
)

View File

@ -0,0 +1,51 @@
package com.readrops.db.queries
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import com.readrops.db.filters.MainFilter
import org.intellij.lang.annotations.Language
object FoldersAndFeedsQueriesBuilder {
fun buildFoldersAndFeedsQuery(accountId: Int, mainFilter: MainFilter): SupportSQLiteQuery {
val filter = when (mainFilter) {
MainFilter.STARS -> "And Item.starred = 1"
MainFilter.NEW -> "And DateTime(Round(Item.pub_date / 1000), 'unixepoch') Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\") "
else -> ""
}
@Language("SQL")
val query = SimpleSQLiteQuery("""
With main As (Select Folder.id As folderId, Folder.name As folderName, Feed.id As feedId,
Feed.name As feedName, Feed.icon_url As feedIcon, Feed.url As feedUrl, Feed.siteUrl As feedSiteUrl,
Folder.account_id As accountId, Item.read as itemRead
From Folder Left Join Feed On Folder.id = Feed.folder_id Left Join Item On Item.feed_id = Feed.id
Where Feed.folder_id is NULL OR Feed.folder_id is NOT NULL And Feed.account_id = $accountId $filter)
Select folderId, folderName, feedId, feedName, feedIcon, feedUrl, feedSiteUrl, accountId,
(Select count(*) From main Where (itemRead = 0)) as unreadCount
From main Group by feedId, folderId Order By folderName, feedName
""".trimIndent())
return query
}
fun buildFeedsWithoutFolderQuery(accountId: Int, mainFilter: MainFilter): SupportSQLiteQuery {
val filter = when (mainFilter) {
MainFilter.STARS -> "And Item.starred = 1 "
MainFilter.NEW -> "And DateTime(Round(Item.pub_date / 1000), 'unixepoch') Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\") "
else -> ""
}
@Language("SQL")
val query = SimpleSQLiteQuery("""
With main As (Select Feed.id As feedId, Feed.name As feedName, Feed.icon_url As feedIcon,
Feed.url As feedUrl, Feed.siteUrl As feedSiteUrl, Feed.account_id As accountId, Item.read As itemRead
From Feed Left Join Item On Feed.id = Item.feed_id Where Feed.folder_id is Null And Feed.account_id = $accountId $filter)
Select feedId, feedName, feedIcon, feedUrl, feedSiteUrl, accountId,
(Select count(*) From main Where (itemRead = 0)) as unreadCount From main Group by feedId Order By feedName
""".trimIndent())
return query
}
}

View File

@ -2,90 +2,121 @@ package com.readrops.db.queries
import androidx.sqlite.db.SupportSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQueryBuilder
import com.readrops.db.filters.FilterType
import com.readrops.db.filters.ListSortType
import com.readrops.db.filters.MainFilter
import com.readrops.db.filters.SubFilter
object ItemsQueryBuilder {
private val COLUMNS = arrayOf("Item.id", "Item.remoteId", "title", "clean_description", "image_link", "pub_date", "link",
"read_it_later", "Feed.name", "text_color", "background_color", "icon_url", "read_time",
"Feed.id as feedId", "Feed.account_id", "Folder.id as folder_id", "Folder.name as folder_name")
private val COLUMNS = arrayOf(
"Item.id",
"Item.remoteId",
"title",
"clean_description",
"image_link",
"pub_date",
"link",
"read_it_later",
"Feed.name",
"text_color",
"background_color",
"icon_url",
"read_time",
"Feed.id as feedId",
"Feed.account_id",
"Folder.id as folder_id",
"Folder.name as folder_name"
)
private val SEPARATE_STATE_COLUMNS = arrayOf("case When ItemState.remote_id is NULL Or ItemState.read = 1 Then 1 else 0 End read",
"case When ItemState.remote_id is NULL or ItemState.starred = 1 Then 1 else 0 End starred")
private val SEPARATE_STATE_COLUMNS = arrayOf(
"case When ItemState.remote_id is NULL Or ItemState.read = 1 Then 1 else 0 End read",
"case When ItemState.remote_id is NULL or ItemState.starred = 1 Then 1 else 0 End starred"
)
private val OTHER_COLUMNS = arrayOf("read", "starred")
private val SELECT_ALL_JOIN = """Item INNER JOIN Feed on Item.feed_id = Feed.id
LEFT JOIN Folder on Feed.folder_id = Folder.id """.trimIndent()
private const val SEPARATE_STATE_JOIN = "LEFT JOIN ItemState On Item.remoteId = ItemState.remote_id"
private const val SEPARATE_STATE_JOIN =
"LEFT JOIN ItemState On Item.remoteId = ItemState.remote_id"
private const val ORDER_BY_ASC = "pub_date DESC"
private const val ORDER_BY_DESC = "pub_date ASC"
@JvmStatic
fun buildItemsQuery(queryFilters: QueryFilters, separateState: Boolean): SupportSQLiteQuery =
buildQuery(queryFilters, separateState)
buildQuery(queryFilters, separateState)
@JvmStatic
fun buildItemsQuery(queryFilters: QueryFilters): SupportSQLiteQuery =
buildQuery(queryFilters, false)
buildQuery(queryFilters, false)
private fun buildQuery(queryFilters: QueryFilters, separateState: Boolean): SupportSQLiteQuery = with(queryFilters) {
if (accountId == 0)
throw IllegalArgumentException("AccountId must be greater than 0")
private fun buildQuery(queryFilters: QueryFilters, separateState: Boolean): SupportSQLiteQuery =
with(queryFilters) {
if (accountId == 0)
throw IllegalArgumentException("AccountId must be greater than 0")
if (filterType == FilterType.FEED_FILTER && filterFeedId == 0)
throw IllegalArgumentException("FeedId must be greater than 0 if current filter is FEED_FILTER")
if (queryFilters.subFilter == SubFilter.FEED && filterFeedId == 0)
throw IllegalArgumentException("FeedId must be greater than 0 if current filter is FEED_FILTER")
val columns = if (separateState) COLUMNS.plus(SEPARATE_STATE_COLUMNS) else COLUMNS.plus(OTHER_COLUMNS)
val selectAllJoin = if (separateState) SELECT_ALL_JOIN + SEPARATE_STATE_JOIN else SELECT_ALL_JOIN
SupportSQLiteQueryBuilder.builder(selectAllJoin).run {
columns(columns)
selection(buildWhereClause(this@with, separateState), null)
orderBy(if (sortType == ListSortType.NEWEST_TO_OLDEST) ORDER_BY_ASC else ORDER_BY_DESC)
create()
}
}
private fun buildWhereClause(queryFilters: QueryFilters, separateState: Boolean): String = StringBuilder(500).run {
append("Feed.account_id = ${queryFilters.accountId} And ")
if (!queryFilters.showReadItems) {
if (separateState)
append("ItemState.read = 0 And ")
val columns = if (separateState)
COLUMNS.plus(SEPARATE_STATE_COLUMNS)
else
append("Item.read = 0 And ")
}
COLUMNS.plus(OTHER_COLUMNS)
when (queryFilters.filterType) {
FilterType.FEED_FILTER -> append("feed_id = ${queryFilters.filterFeedId} And read_it_later = 0")
FilterType.FOLDER_FILER -> append("folder_id = ${queryFilters.filterFolderId} And read_it_later = 0")
FilterType.READ_IT_LATER_FILTER -> append("read_it_later = 1")
FilterType.STARS_FILTER -> {
if (separateState) {
append("ItemState.starred = 1 And read_it_later = 0")
} else {
append("starred = 1 And read_it_later = 0")
}
val selectAllJoin =
if (separateState) SELECT_ALL_JOIN + SEPARATE_STATE_JOIN else SELECT_ALL_JOIN
SupportSQLiteQueryBuilder.builder(selectAllJoin).run {
columns(columns)
selection(buildWhereClause(this@with, separateState), null)
orderBy(if (sortType == ListSortType.NEWEST_TO_OLDEST) ORDER_BY_ASC else ORDER_BY_DESC)
create()
}
else -> append("read_it_later = 0")
}
toString()
}
private fun buildWhereClause(queryFilters: QueryFilters, separateState: Boolean): String =
StringBuilder(500).run {
append("Feed.account_id = ${queryFilters.accountId} And ")
if (!queryFilters.showReadItems) {
if (separateState)
append("ItemState.read = 0 And ")
else
append("Item.read = 0 And ")
}
when (queryFilters.mainFilter) {
MainFilter.STARS -> {
if (separateState) {
append("ItemState.starred = 1 And read_it_later = 0 ")
} else {
append("starred = 1 And read_it_later = 0 ")
}
}
MainFilter.NEW -> append("DateTime(Round(pub_date / 1000), 'unixepoch') Between DateTime(DateTime(\"now\"), \"-24 hour\") And DateTime(\"now\") ")
else -> append("read_it_later = 0 ")
}
when (queryFilters.subFilter) {
SubFilter.FEED -> append("And feed_id = ${queryFilters.filterFeedId} And read_it_later = 0")
SubFilter.FOLDER -> append("And folder_id = ${queryFilters.filterFolderId} And read_it_later = 0")
else -> {}
}
toString()
}
}
data class QueryFilters(
var showReadItems: Boolean = true,
var filterFeedId: Int = 0,
var filterFolderId: Int = 0,
var accountId: Int = 0,
var filterType: FilterType = FilterType.NO_FILTER,
var sortType: ListSortType = ListSortType.NEWEST_TO_OLDEST,
val showReadItems: Boolean = true,
val filterFeedId: Int = 0,
val filterFolderId: Int = 0,
val accountId: Int = 0,
val mainFilter: MainFilter = MainFilter.ALL,
val subFilter: SubFilter = SubFilter.ALL,
val sortType: ListSortType = ListSortType.NEWEST_TO_OLDEST,
)

65
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,65 @@
[versions]
compose-bom = "2024.02.02"
voyager = "1.0.0"
lifecycle = "2.7.0"
coil = "2.4.0"
coroutines = "1.8.0"
room = "2.6.1"
koin-bom = "3.5.0"
[libraries]
bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-runtime = { module = "androidx.compose.runtime:runtime" }
compose-animation = { module = "androidx.compose.animation:animation" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
# specify material3 version is required for gradle to find the dependency
compose-material3 = { module = "androidx.compose.material3:material3", version = "1.2.1" }
compose-activity = "androidx.activity:activity-compose:1.8.2"
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager-tab-navigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycle" }
lifecyle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
coil-core = { module = "io.coil-kt:coil", version.ref = "coil" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-rxjava2 = { module = "androidx.room:room-rxjava2", version.ref = "room" } #TODO remove usage of this
room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-android = { module = "io.insert-koin:koin-android" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose" }
koin-android-compat = { module = "io.insert-koin:koin-android-compat" } #TODO remove usage of this
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-bom" }
koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin-bom" }
[bundles]
compose = ["bom", "compose-foundation", "compose-runtime", "compose-animation",
"compose-ui", "compose-ui-tooling", "compose-ui-tooling-preview", "compose-material3"]
voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-koin", "voyager-transitions"]
lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycle-viewmodel-savedstate",
"lifecyle-runtime-compose"]
coil = ["coil-core", "coil-compose"]
coroutines = ["coroutines-core", "coroutines-android"]
room = ["room-runtime", "room-ktx", "room-rxjava2", "room-paging"]
koin = ["koin-bom", "koin-core", "koin-android", "koin-androidx-compose", "koin-android-compat"]
kointest = ["koin-test", "koin-test-junit4"]