diff --git a/Gemfile.lock b/Gemfile.lock index 7d2cdbfe..4269fb18 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,32 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) + CFPropertyList (3.0.7) + base64 + nkf rexml addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) - artifactory (3.0.15) + artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.880.0) - aws-sdk-core (3.190.2) + aws-partitions (1.909.0) + aws-sdk-core (3.191.6) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.76.0) - aws-sdk-core (~> 3, >= 3.188.0) + aws-sdk-kms (1.78.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + aws-sdk-s3 (1.146.1) + aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.8) aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) + base64 (0.2.0) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -35,7 +38,7 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.109.0) + excon (0.110.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -64,15 +67,15 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) - fastimage (2.3.0) - fastlane (2.219.0) + fastimage (2.3.1) + fastlane (2.220.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) - colored + colored (~> 1.2) commander (~> 4.6) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) @@ -93,10 +96,10 @@ GEM mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) naturally (~> 2.2) - optparse (>= 0.1.1) + optparse (>= 0.1.1, < 1.0.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) terminal-table (~> 3) @@ -105,11 +108,11 @@ GEM word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.2) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -117,19 +120,18 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml - webrick google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.6.1) + google-cloud-core (1.7.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) + google-cloud-errors (1.4.0) google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) @@ -149,19 +151,21 @@ GEM domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.6.2) - json (2.7.1) - jwt (2.7.1) + json (2.7.2) + jwt (2.8.1) + base64 mini_magick (4.12.0) mini_mime (1.1.5) multi_json (1.15.0) - multipart-post (2.3.0) + multipart-post (2.4.0) nanaimo (0.3.0) naturally (2.2.1) + nkf (0.2.0) optparse (0.4.0) os (1.1.4) plist (3.7.1) - public_suffix (5.0.4) - rake (13.1.0) + public_suffix (5.0.5) + rake (13.2.1) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -171,8 +175,8 @@ GEM rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) - security (0.1.3) - signet (0.18.0) + security (0.1.5) + signet (0.19.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) @@ -190,9 +194,8 @@ GEM tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.5.0) - webrick (1.8.1) word_wrap (1.0.0) - xcodeproj (1.23.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e4859124..6264603f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,11 +12,11 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.BasilReader" - tools:targetApi="31"> + android:enableOnBackInvokedCallback="true" + tools:targetApi="33"> diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt index 98ca64d5..008bdafd 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/AccountViewModel.kt @@ -15,7 +15,6 @@ import com.jocmp.basil.Folder import com.jocmp.basil.buildPager import com.jocmp.basil.countAll import com.jocmp.basilreader.common.AppPreferences -import com.jocmp.basilreader.refresher.RefreshScheduler import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -29,7 +28,6 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class AccountViewModel( private val account: Account, - private val refreshScheduler: RefreshScheduler, private val appPreferences: AppPreferences, ) : ViewModel() { private var refreshJob: Job? = null @@ -51,6 +49,8 @@ class AccountViewModel( .withPositiveCount(filterStatus) } + val allFeeds = account.allFeeds + val feeds = account.feeds.combine(_counts) { feeds, latestCounts -> feeds.map { copyFeedCounts(it, latestCounts) } .withPositiveCount(filterStatus) @@ -80,7 +80,7 @@ class AccountViewModel( suspend fun selectFeed(feedID: String) { val feed = account.findFeed(feedID) ?: return - val feedFilter = ArticleFilter.Feeds(feed = feed, feedStatus = filter.value.status) + val feedFilter = ArticleFilter.Feeds(feedID = feed.id, feedStatus = filter.value.status) selectArticleFilter(feedFilter) } @@ -89,7 +89,7 @@ class AccountViewModel( viewModelScope.launch { val folder = account.findFolder(title) ?: return@launch val feedFilter = - ArticleFilter.Folders(folder = folder, folderStatus = filter.value.status) + ArticleFilter.Folders(folderTitle = folder.title, folderStatus = filter.value.status) selectArticleFilter(feedFilter) } @@ -102,19 +102,12 @@ class AccountViewModel( } } - fun removeFolder(folderTitle: String) { - viewModelScope.launch { - account.removeFolder(title = folderTitle) - resetToDefaultFilter() - } - } - fun refreshFeed(onComplete: () -> Unit) { refreshJob?.cancel() refreshJob = viewModelScope.launch(Dispatchers.IO) { account.refresh() - + onComplete() } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleLayout.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleLayout.kt index 0877e6ed..5b4aec88 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleLayout.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleLayout.kt @@ -45,7 +45,6 @@ import com.jocmp.basilreader.ui.components.rememberWebViewNavigator import com.jocmp.basilreader.ui.fixtures.FeedPreviewFixture import com.jocmp.basilreader.ui.fixtures.FolderPreviewFixture import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -60,6 +59,7 @@ fun ArticleLayout( filter: ArticleFilter, folders: List, feeds: List, + allFeeds: List, articles: Flow>, article: Article?, statusCount: Long, @@ -69,10 +69,8 @@ fun ArticleLayout( onSelectArticleFilter: () -> Unit, onSelectStatus: (status: ArticleStatus) -> Unit, onSelectArticle: (articleID: String, completion: (article: Article) -> Unit) -> Unit, - onEditFolder: (folderTitle: String) -> Unit, onEditFeed: (feedID: String) -> Unit, onRemoveFeed: (feedID: String) -> Unit, - onRemoveFolder: (folderTitle: String) -> Unit, onNavigateToAccounts: () -> Unit, onClearArticle: () -> Unit, onToggleArticleRead: () -> Unit, @@ -94,6 +92,7 @@ fun ArticleLayout( val state = rememberPullToRefreshState() val snackbarHost = remember { SnackbarHostState() } val addFeedSuccessMessage = stringResource(R.string.add_feed_success) + val currentFeed = findCurrentFeed(filter, allFeeds) val navigateToDetail = { navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) @@ -168,7 +167,13 @@ fun ArticleLayout( Scaffold( topBar = { TopAppBar( - title = { FilterAppBarTitle(filter) }, + title = { + FilterAppBarTitle( + filter = filter, + allFeeds = allFeeds, + folders = folders, + ) + }, navigationIcon = { IconButton(onClick = { coroutineScope.launch { drawerState.open() } }) { Icon( @@ -178,13 +183,13 @@ fun ArticleLayout( } }, actions = { - FilterActionMenu( - filter = filter, - onFeedEdit = onEditFeed, - onFolderEdit = onEditFolder, - onRemoveFeed = onRemoveFeed, - onRemoveFolder = onRemoveFolder, - ) + if (currentFeed != null) { + FilterActionMenu( + feed = currentFeed, + onFeedEdit = onEditFeed, + onRemoveFeed = onRemoveFeed, + ) + } } ) }, @@ -251,6 +256,14 @@ fun ArticleLayout( } } +fun findCurrentFeed(filter: ArticleFilter, feeds: List): Feed? { + if (filter is ArticleFilter.Feeds) { + return feeds.find { it.id == filter.feedID } + } + + return null +} + @Preview @Composable fun ArticleLayoutPreview() { @@ -260,26 +273,25 @@ fun ArticleLayoutPreview() { MaterialTheme { ArticleLayout( filter = ArticleFilter.default(), + allFeeds = emptyList(), folders = folders, feeds = feeds, articles = emptyFlow(), article = null, statusCount = 30, - drawerValue = DrawerValue.Open, onFeedRefresh = {}, onSelectFolder = {}, onSelectFeed = {}, onSelectArticleFilter = { }, onSelectStatus = {}, onSelectArticle = { _, _ -> }, - onEditFolder = {}, onEditFeed = {}, onRemoveFeed = {}, - onRemoveFolder = {}, onNavigateToAccounts = { }, onClearArticle = { }, onToggleArticleRead = { }, onToggleArticleStar = {}, + drawerValue = DrawerValue.Open, ) } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt index 5a94f5ed..4e11a3b9 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleNavigation.kt @@ -31,42 +31,18 @@ fun NavGraphBuilder.articleGraph( ArticleScreen( onEditFeed = { feedID -> navController.navigateToEditFeed(feedID = feedID) - }, - onEditFolder = { folderTitle -> - navController.navigateToEditFolder(folderTitle = folderTitle) - }, - onNavigateToAccounts = { - navController.navigate(Route.AccountIndex) } - ) + ) { + navController.navigate(Route.AccountIndex) + } } dialog( route = "feeds/{id}/edit", ) { EditFeedScreen( onSubmit = { - navController.navigate(articlesRoute) { - popUpTo(articlesRoute) { - inclusive = true - } - } - }, - onCancel = { navController.popBackStack() }, - ) - } - dialog( - route = "folders/{title}/edit", - ) { - EditFolderScreen( - onSubmit = { - navController.navigate(articlesRoute) { - popUpTo(articlesRoute) { - inclusive = true - } - } - }, onCancel = { navController.popBackStack() }, diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt index e65203e1..b891bb7d 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt @@ -1,22 +1,18 @@ package com.jocmp.basilreader.ui.articles -import android.util.Log import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.androidx.compose.koinViewModel -private const val TAG = "ArticleScreen" - @Composable fun ArticleScreen( viewModel: AccountViewModel = koinViewModel(), onEditFeed: (feedID: String) -> Unit, - onEditFolder: (folderTitle: String) -> Unit, onNavigateToAccounts: () -> Unit, ) { val feeds by viewModel.feeds.collectAsStateWithLifecycle(initialValue = emptyList()) + val allFeeds by viewModel.allFeeds.collectAsStateWithLifecycle(initialValue = emptyList()) val folders by viewModel.folders.collectAsStateWithLifecycle(initialValue = emptyList()) val statusCount by viewModel.statusCount.collectAsStateWithLifecycle(initialValue = 0) val filter by viewModel.filter.collectAsStateWithLifecycle() @@ -25,23 +21,22 @@ fun ArticleScreen( filter = filter, folders = folders, feeds = feeds, + allFeeds = allFeeds, + articles = viewModel.articles, article = viewModel.article, statusCount = statusCount, onFeedRefresh = { completion -> viewModel.refreshFeed(completion) }, - onClearArticle = viewModel::clearArticle, - onEditFeed = onEditFeed, - onEditFolder = onEditFolder, - onNavigateToAccounts = onNavigateToAccounts, - onSelectArticleFilter = viewModel::selectArticleFilter, - onSelectFeed = viewModel::selectFeed, onSelectFolder = viewModel::selectFolder, + onSelectFeed = viewModel::selectFeed, + onSelectArticleFilter = viewModel::selectArticleFilter, onSelectStatus = viewModel::selectStatus, - onRemoveFeed = viewModel::removeFeed, - onRemoveFolder = viewModel::removeFolder, - articles = viewModel.articles, onSelectArticle = viewModel::selectArticle, + onEditFeed = onEditFeed, + onRemoveFeed = viewModel::removeFeed, + onNavigateToAccounts = onNavigateToAccounts, + onClearArticle = viewModel::clearArticle, onToggleArticleRead = viewModel::toggleArticleRead, onToggleArticleStar = viewModel::toggleArticleStar, ) diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt index 724f48c3..f213a17c 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticlesModule.kt @@ -17,21 +17,13 @@ internal val articlesModule = module { AccountViewModel( account = get(parameters = { parametersOf(appPreferences.accountID.get()) }), - refreshScheduler = get(), appPreferences = appPreferences ) } viewModel { EditFeedViewModel( savedStateHandle = get(), - accountManager = get(), - appPreferences = get() - ) - } - viewModel { - EditFolderViewModel( - savedStateHandle = get(), - accountManager = get(), + account = get(), appPreferences = get() ) } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedScreen.kt index 7c9b4dc0..a3989e0f 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedScreen.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedScreen.kt @@ -1,6 +1,16 @@ package com.jocmp.basilreader.ui.articles +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.jocmp.basilreader.ui.components.EmptyView import org.koin.androidx.compose.koinViewModel @Composable @@ -9,13 +19,21 @@ fun EditFeedScreen( onSubmit: () -> Unit, onCancel: () -> Unit ) { - EditFeedView( - feed = viewModel.feed, - folders = viewModel.folders, - feedFoldersTitles = viewModel.feedFolderTitles, - onSubmit = { entry -> - viewModel.submit(entry, onSubmit) - }, - onCancel = onCancel - ) + val feed = viewModel.feed + + Card( + shape = RoundedCornerShape(16.dp) + ) { + feed?.let { + EditFeedView( + feed = feed, + folders = viewModel.folders, + feedFoldersTitles = viewModel.feedFolderTitles, + onSubmit = { entry -> + viewModel.submit(entry, onSubmit) + }, + onCancel = onCancel + ) + } ?: EmptyView(showLoading = true) + } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedView.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedView.kt index b2eba9a5..7353c1c2 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedView.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedView.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -35,7 +35,8 @@ fun EditFeedView( onSubmit: (feed: EditFeedForm) -> Unit, onCancel: () -> Unit ) { - val (name, setName) = remember { mutableStateOf(feed.name) } + val scrollState = rememberScrollState() + val (name, setName) = remember { mutableStateOf(feed.title) } val (addedFolder, setAddedFolder) = remember { mutableStateOf("") } val switchFolders = remember { folders.map { it.title to feedFoldersTitles.contains(it.title) }.toMutableStateMap() @@ -48,58 +49,58 @@ fun EditFeedView( onSubmit( EditFeedForm( feedID = feed.id, - name = name, + title = name, folderTitles = folderNames ) ) } - Card( - shape = RoundedCornerShape(16.dp) + Column( + Modifier + .padding(16.dp) + .verticalScroll(scrollState) ) { - Column(Modifier.padding(16.dp)) { - TextField( - value = name, - onValueChange = setName, - placeholder = { Text(feed.name) }, - label = { - Text(stringResource(id = R.string.add_feed_name_title)) - } - ) - TextField( - value = addedFolder, - onValueChange = setAddedFolder, - placeholder = { - Text(stringResource(id = R.string.add_feed_new_folder_title)) - } - ) - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - switchFolders.forEach { (folderTitle, checked) -> - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Text(folderTitle) - Switch( - checked = checked, - onCheckedChange = { value -> switchFolders[folderTitle] = value } - ) - } - } + TextField( + value = name, + onValueChange = setName, + placeholder = { Text(feed.title) }, + label = { + Text(stringResource(id = R.string.add_feed_name_title)) } - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier.fillMaxWidth() - ) { - TextButton(onClick = onCancel) { - Text(stringResource(R.string.feed_form_cancel)) - } - Button(onClick = { submitFeed() }) { - Text(stringResource(R.string.edit_feed_submit)) + ) + TextField( + value = addedFolder, + onValueChange = setAddedFolder, + placeholder = { + Text(stringResource(id = R.string.add_feed_new_folder_title)) + } + ) + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + switchFolders.forEach { (folderTitle, checked) -> + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text(folderTitle) + Switch( + checked = checked, + onCheckedChange = { value -> switchFolders[folderTitle] = value } + ) } } } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.feed_form_cancel)) + } + Button(onClick = { submitFeed() }) { + Text(stringResource(R.string.edit_feed_submit)) + } + } } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedViewModel.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedViewModel.kt index 7137f4f0..9b13b1dd 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedViewModel.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFeedViewModel.kt @@ -1,34 +1,54 @@ package com.jocmp.basilreader.ui.articles +import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.jocmp.basil.AccountManager +import com.jocmp.basil.Account import com.jocmp.basil.ArticleFilter import com.jocmp.basil.EditFeedForm import com.jocmp.basil.Feed import com.jocmp.basil.Folder import com.jocmp.basil.preferences.getAndSet import com.jocmp.basilreader.common.AppPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext class EditFeedViewModel( savedStateHandle: SavedStateHandle, - accountManager: AccountManager, + private val account: Account, private val appPreferences: AppPreferences ) : ViewModel() { - private val account = accountManager.findByID(appPreferences.accountID.get())!! private val args = EditFeedArgs(savedStateHandle) - val feed: Feed - get() = runBlocking { account.findFeed(args.feedID)!! } + private val _feed = mutableStateOf(null) - val feedFolderTitles: List - get() = emptyList() // account.folders.filter { it.feeds.contains(feed) }.map { it.title } + private val _folders = mutableStateOf>(emptyList()) + + init { + viewModelScope.launch(Dispatchers.IO) { + val feed = account.findFeed(args.feedID) + val folders = account.folders.first() + + withContext(Dispatchers.Main) { + _feed.value = feed + _folders.value = folders + } + } + } + + val feed: Feed? + get() = _feed.value val folders: List - get() = emptyList() // account.folders.toList() + get() = _folders.value + + val feedFolderTitles: List + get() = folders + .filter { folder -> folder.feeds.any { it.id == feed?.id } } + .map { it.title } fun submit(form: EditFeedForm, onSuccess: () -> Unit) { viewModelScope.launch { @@ -37,7 +57,7 @@ class EditFeedViewModel( .onSuccess { feed -> appPreferences.filter.getAndSet { filter -> if (filter.isFeedSelected(feed)) { - ArticleFilter.Feeds(feed, filter.status) + ArticleFilter.Feeds(feed.id, filter.status) } else { filter } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderScreen.kt deleted file mode 100644 index 76de9eb4..00000000 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderScreen.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.jocmp.basilreader.ui.articles - -import androidx.compose.runtime.Composable -import org.koin.androidx.compose.koinViewModel - -@Composable -fun EditFolderScreen( - viewModel: EditFolderViewModel = koinViewModel(), - onSubmit: () -> Unit, - onCancel: () -> Unit, -) { - EditFolderView( - folder = viewModel.folder, - onSubmit = { - viewModel.submit( - form = it, - onSubmit = onSubmit - ) - }, - onCancel = onCancel, - ) -} diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderView.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderView.kt deleted file mode 100644 index 1ee5613f..00000000 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderView.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.jocmp.basilreader.ui.articles - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.jocmp.basil.EditFolderForm -import com.jocmp.basil.Folder -import com.jocmp.basilreader.R -import com.jocmp.basilreader.ui.fixtures.FolderPreviewFixture - -@Composable -fun EditFolderView( - folder: Folder, - onSubmit: (form: EditFolderForm) -> Unit, - onCancel: () -> Unit, -) { - val (folderTitle, setFolderTitle) = remember { mutableStateOf(folder.title) } - - val submit = { - onSubmit( - EditFolderForm( - existingTitle = folder.title, - title = folderTitle, - ) - ) - } - - Card( - shape = RoundedCornerShape(16.dp) - ) { - Column(Modifier.padding(16.dp)) { - TextField( - value = folderTitle, - onValueChange = setFolderTitle, - placeholder = { - Text(folder.title) - } - ) - Row( - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = onCancel) { - Text(stringResource(R.string.feed_form_cancel)) - } - Button(onClick = submit) { - Text(stringResource(R.string.edit_feed_submit)) - } - } - } - } -} - -@Preview -@Composable -fun EditFolderViewPreview() { - EditFolderView( - folder = FolderPreviewFixture().values.first(), - onSubmit = {}, - onCancel = {} - ) -} diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderViewModel.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderViewModel.kt deleted file mode 100644 index b3c76891..00000000 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/EditFolderViewModel.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.jocmp.basilreader.ui.articles - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.jocmp.basil.AccountManager -import com.jocmp.basil.ArticleFilter -import com.jocmp.basil.EditFolderForm -import com.jocmp.basil.Folder -import com.jocmp.basilreader.common.AppPreferences -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -class EditFolderViewModel( - savedStateHandle: SavedStateHandle, - accountManager: AccountManager, - private val appPreferences: AppPreferences -) : ViewModel() { - private val account = accountManager.findByID(appPreferences.accountID.get())!! - private val args = EditFolderArgs(savedStateHandle) - - val folder: Folder - get() = runBlocking { account.findFolder(args.folderTitle)!! } - - fun submit(form: EditFolderForm, onSubmit: () -> Unit) { - viewModelScope.launch { - account.editFolder(form = form).onSuccess { updatedFolder -> - val folderFilter = appPreferences.filter.get() as? ArticleFilter.Folders - - folderFilter?.let { filter -> - if (filter.folder.title == form.existingTitle) { - appPreferences.filter.set(filter.copy(folder = updatedFolder)) - } - } - - onSubmit() - } - } - } -} diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedActionMenuItems.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedActionMenuItems.kt index d4193f12..f044d8d1 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedActionMenuItems.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedActionMenuItems.kt @@ -12,7 +12,7 @@ import com.jocmp.basilreader.ui.fixtures.FeedPreviewFixture @Composable fun FeedActionMenuItems( - feed: Feed, + feedID: String, onMenuClose: () -> Unit, onRequestRemove: () -> Unit, onEdit: (feedID: String) -> Unit, @@ -23,7 +23,7 @@ fun FeedActionMenuItems( }, onClick = { onMenuClose() - onEdit(feed.id) + onEdit(feedID) } ) DropdownMenuItem( @@ -42,7 +42,7 @@ fun FeedActionMenuItems( fun FeedActionMenuPreview() { DropdownMenu(expanded = true, onDismissRequest = {}) { FeedActionMenuItems( - feed = FeedPreviewFixture().values.first(), + feedID = FeedPreviewFixture().values.first().id, onMenuClose = {}, onEdit = {}, onRequestRemove = {} diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedRow.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedRow.kt index 2b9454f4..3436d0f4 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedRow.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/FeedRow.kt @@ -15,7 +15,7 @@ fun FeedRow( ) { NavigationDrawerItem( icon = { FaviconBadge(url = feed.faviconURL) }, - label = { ListTitle(feed.name) }, + label = { ListTitle(feed.title) }, badge = { CountBadge(count = feed.count) }, selected = selected, onClick = { diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterActionMenu.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterActionMenu.kt index ca154fba..418c02a1 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterActionMenu.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterActionMenu.kt @@ -13,16 +13,15 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.jocmp.basil.ArticleFilter import com.jocmp.basil.ArticleStatus +import com.jocmp.basil.Feed import com.jocmp.basilreader.R import com.jocmp.basilreader.ui.fixtures.FeedPreviewFixture @Composable fun FilterActionMenu( - filter: ArticleFilter, + feed: Feed, onFeedEdit: (feedID: String) -> Unit, - onFolderEdit: (folderTitle: String) -> Unit, onRemoveFeed: (feedID: String) -> Unit, - onRemoveFolder: (folderTitle: String) -> Unit, ) { val (expanded, setMenuExpanded) = remember { mutableStateOf(false) } val (showRemoveDialog, setRemoveDialogOpen) = remember { mutableStateOf(false) } @@ -34,15 +33,7 @@ fun FilterActionMenu( val onRemove = { setRemoveDialogOpen(false) - if (filter is ArticleFilter.Feeds) { - onRemoveFeed(filter.feed.id) - } else if (filter is ArticleFilter.Folders) { - onRemoveFolder(filter.folder.title) - } - } - - if (filter is ArticleFilter.Articles) { - return + onRemoveFeed(feed.id) } Box { @@ -57,28 +48,17 @@ fun FilterActionMenu( expanded = expanded, onDismissRequest = { setMenuExpanded(false) }, ) { - if (filter is ArticleFilter.Feeds) { - FeedActionMenuItems( - feed = filter.feed, - onMenuClose = { setMenuExpanded(false) }, - onRequestRemove = onRequestRemove, - onEdit = onFeedEdit, - ) - } - - if (filter is ArticleFilter.Folders) { - FolderActionMenuItems( - folder = filter.folder, - onMenuClose = { setMenuExpanded(false) }, - onRequestRemove = onRequestRemove, - onEdit = onFolderEdit, - ) - } + FeedActionMenuItems( + feedID = feed.id, + onMenuClose = { setMenuExpanded(false) }, + onRequestRemove = onRequestRemove, + onEdit = onFeedEdit, + ) } if (showRemoveDialog) { RemoveDialog( - filter = filter, + feed = feed, onRemove = onRemove, onDismiss = { setRemoveDialogOpen(false) } ) @@ -92,10 +72,8 @@ fun FilterActionMenuPreview() { val feed = FeedPreviewFixture().values.first() FilterActionMenu( - filter = ArticleFilter.Feeds(feed = feed, feedStatus = ArticleStatus.ALL), + feed = feed, onFeedEdit = {}, - onFolderEdit = {}, onRemoveFeed = {}, - onRemoveFolder = {} ) } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterAppBarTitle.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterAppBarTitle.kt index 00b94992..8b049a89 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterAppBarTitle.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/FilterAppBarTitle.kt @@ -7,14 +7,25 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.jocmp.basil.ArticleFilter +import com.jocmp.basil.Feed +import com.jocmp.basil.Folder import com.jocmp.basilreader.ui.navigationTitle @Composable -fun FilterAppBarTitle(filter: ArticleFilter) { +fun FilterAppBarTitle( + filter: ArticleFilter, + allFeeds: List, + folders: List, +) { val text = when (filter) { is ArticleFilter.Articles -> stringResource(filter.articleStatus.navigationTitle) - is ArticleFilter.Feeds -> filter.feed.name - is ArticleFilter.Folders -> filter.folder.title + is ArticleFilter.Feeds -> { + allFeeds.find { it.id == filter.feedID }?.title.orEmpty() + } + + is ArticleFilter.Folders -> { + folders.find { it.title == filter.folderTitle }?.title.orEmpty() + } } Text( @@ -28,6 +39,10 @@ fun FilterAppBarTitle(filter: ArticleFilter) { @Composable fun FilterAppBarTitlePreview() { MaterialTheme { - FilterAppBarTitle(ArticleFilter.default()) + FilterAppBarTitle( + filter = ArticleFilter.default(), + allFeeds = emptyList(), + folders = emptyList(), + ) } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/FolderRow.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/FolderRow.kt index f4b72614..a8cd0c1e 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/FolderRow.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/FolderRow.kt @@ -133,7 +133,7 @@ fun FolderIconButton( fun FolderRowPreview() { val folder = FolderPreviewFixture().values.take(1).first() val filter = ArticleFilter.Folders( - folder = folder, + folderTitle = folder.title, folderStatus = ArticleStatus.ALL ) diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/RemoveDialog.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/RemoveDialog.kt index f03aa8c3..79247f6d 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/RemoveDialog.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/RemoveDialog.kt @@ -7,15 +7,16 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.jocmp.basil.ArticleFilter +import com.jocmp.basil.Feed import com.jocmp.basilreader.R @Composable fun RemoveDialog( - filter: ArticleFilter, + feed: Feed, onRemove: () -> Unit, onDismiss: () -> Unit ) { - val state = removeDialogState(filter) ?: return + val state = removeDialogState(feed = feed) AlertDialog( onDismissRequest = onDismiss, @@ -35,22 +36,12 @@ fun RemoveDialog( } @Composable -private fun removeDialogState(filter: ArticleFilter): RemoveDialogState? { - return when (filter) { - is ArticleFilter.Feeds -> RemoveDialogState( - title = stringResource(R.string.feed_action_unsubscribe_title), - message = stringResource(R.string.feed_action_unsubscribe_message, filter.feed.name), - confirmText = stringResource(R.string.feed_action_unsubscribe_confirm) - ) - - is ArticleFilter.Folders -> RemoveDialogState( - title = stringResource(R.string.folder_action_delete_title), - message = stringResource(R.string.folder_action_delete_message, filter.folder.title), - confirmText = stringResource(R.string.folder_action_delete_confirm) - ) - - else -> null - } +private fun removeDialogState(feed: Feed): RemoveDialogState { + return RemoveDialogState( + title = stringResource(R.string.feed_action_unsubscribe_title), + message = stringResource(R.string.feed_action_unsubscribe_message, feed.title), + confirmText = stringResource(R.string.feed_action_unsubscribe_confirm) + ) } data class RemoveDialogState( diff --git a/app/src/main/java/com/jocmp/basilreader/ui/components/EmptyView.kt b/app/src/main/java/com/jocmp/basilreader/ui/components/EmptyView.kt index d436774f..0b341347 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/components/EmptyView.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/components/EmptyView.kt @@ -2,16 +2,40 @@ package com.jocmp.basilreader.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview @Composable -fun EmptyView(fillSize: Boolean = false) { +fun EmptyView( + fillSize: Boolean = false, + showLoading: Boolean = false, +) { val modifier = if (fillSize) { Modifier.fillMaxSize() } else { - Modifier + Modifier.fillMaxWidth() } - Column(modifier) {} + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (showLoading) { + CircularProgressIndicator( + modifier = Modifier + ) + } + } +} + +@Preview +@Composable +private fun EmptyViewPreview() { + EmptyView( + showLoading = true + ) } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FeedPreviewFixture.kt b/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FeedPreviewFixture.kt index d1b93543..c28c9361 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FeedPreviewFixture.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FeedPreviewFixture.kt @@ -13,13 +13,13 @@ class FeedPreviewFixture : PreviewParameterProvider { id = RandomUUID.generate(), subscriptionID = RandomUUID.generate(), count = 10, - name = "GamersNexus", + title = "GamersNexus", feedURL = "https://gamersnexus.net/rss.xml" ), Feed( id = RandomUUID.generate(), subscriptionID = RandomUUID.generate(), - name = "9to5Google", + title = "9to5Google", feedURL = "https://9to5google.com/feed/" ) ) diff --git a/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FolderPreviewFixture.kt b/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FolderPreviewFixture.kt index 34db59c2..2eb132fd 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FolderPreviewFixture.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/fixtures/FolderPreviewFixture.kt @@ -18,14 +18,14 @@ class FolderPreviewFixture : PreviewParameterProvider { id = RandomUUID.generate(), subscriptionID = RandomUUID.generate(), count = 3, - name = "The Verge", + title = "The Verge", feedURL = "https://www.theverge.com/rss/index.xml" ), Feed( id = RandomUUID.generate(), subscriptionID = RandomUUID.generate(), count = 0, - name = "Ars Technica", + title = "Ars Technica", feedURL = "https://arstechnica.com/feed/" ) ) @@ -36,13 +36,13 @@ class FolderPreviewFixture : PreviewParameterProvider { Feed( id = RandomUUID.generate(), subscriptionID = RandomUUID.generate(), - name = "Android Weekly", + title = "Android Weekly", feedURL = "" ), Feed( id = RandomUUID.generate(), subscriptionID = RandomUUID.generate(), - name = "Ruby Weekly", + title = "Ruby Weekly", feedURL = "" ), ) diff --git a/basil/src/main/java/com/jocmp/basil/Account.kt b/basil/src/main/java/com/jocmp/basil/Account.kt index 27bf07a2..be594ad4 100644 --- a/basil/src/main/java/com/jocmp/basil/Account.kt +++ b/basil/src/main/java/com/jocmp/basil/Account.kt @@ -11,24 +11,33 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.net.URI -private const val TAG = "Account" +interface AccountDelegate { + suspend fun addFeed(url: String): Result + suspend fun addStar(articleIDs: List) + suspend fun refresh() + suspend fun removeStar(articleIDs: List) + suspend fun markRead(articleIDs: List) + suspend fun markUnread(articleIDs: List) + suspend fun updateFeed(feed: Feed, title: String, folderTitles: List): Result + + suspend fun removeFeed(feedID: String) +} data class Account( val id: String, val path: URI, val database: Database, val preferences: AccountPreferences, -) { - private val delegate = FeedbinAccountDelegate( + val delegate: AccountDelegate = FeedbinAccountDelegate( database = database, - feedbin = Feedbin.forAccount(this) + feedbin = Feedbin.forAccount(path, preferences) ) - +) { internal val articleRecords: ArticleRecords = ArticleRecords(database) private val feedRecords: FeedRecords = FeedRecords(database) - private val allFeeds = feedRecords.feeds() + val allFeeds = feedRecords.feeds() val feeds: Flow> = allFeeds.map { all -> all.filter { it.folderName.isBlank() } @@ -46,13 +55,6 @@ data class Account( } } - suspend fun removeFolder(title: String) { - } - - suspend fun removeFeed(feedID: String) { - feedRecords.removeFeed(feedID = feedID) - } - suspend fun addFeed(url: String): Result { return delegate.addFeed(url) } @@ -60,18 +62,15 @@ data class Account( suspend fun editFeed(form: EditFeedForm): Result { val feed = findFeed(form.feedID) ?: return Result.failure(Throwable("Feed not found")) - val editedFeed = feed.copy(name = form.name) - - return Result.success(editedFeed) + return delegate.updateFeed( + feed = feed, + title = form.title, + folderTitles = form.folderTitles + ) } - suspend fun editFolder(form: EditFolderForm): Result { - val folder = - findFolder(form.existingTitle) ?: return Result.failure(Throwable("Folder not found")) - - val updatedFolder = folder.copy(title = form.title) - - return Result.success(updatedFolder) + suspend fun removeFeed(feedID: String) { + delegate.removeFeed(feedID = feedID) } suspend fun refresh() { diff --git a/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt b/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt index 0135ca02..45dbe401 100644 --- a/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt +++ b/basil/src/main/java/com/jocmp/basil/ArticleFilter.kt @@ -5,11 +5,11 @@ import kotlinx.serialization.Serializable @Serializable sealed class ArticleFilter(open val status: ArticleStatus) { fun isFolderSelect(folder: Folder): Boolean { - return this is Folders && this.folder.title == folder.title + return this is Folders && this.folderTitle == folder.title } fun isFeedSelected(feed: Feed): Boolean { - return this is Feeds && this.feed.id == feed.id + return this is Feeds && this.feedID == feed.id } fun areArticlesSelected(): Boolean { @@ -31,13 +31,13 @@ sealed class ArticleFilter(open val status: ArticleStatus) { } @Serializable - data class Feeds(val feed: Feed, val feedStatus: ArticleStatus) : ArticleFilter(feedStatus) { + data class Feeds(val feedID: String, val feedStatus: ArticleStatus) : ArticleFilter(feedStatus) { override val status: ArticleStatus get() = feedStatus } @Serializable - data class Folders(val folder: Folder, val folderStatus: ArticleStatus) : + data class Folders(val folderTitle: String, val folderStatus: ArticleStatus) : ArticleFilter(folderStatus) { override val status: ArticleStatus get() = folderStatus diff --git a/basil/src/main/java/com/jocmp/basil/EditFeedForm.kt b/basil/src/main/java/com/jocmp/basil/EditFeedForm.kt index 0c7b7296..d3f0e256 100644 --- a/basil/src/main/java/com/jocmp/basil/EditFeedForm.kt +++ b/basil/src/main/java/com/jocmp/basil/EditFeedForm.kt @@ -2,6 +2,6 @@ package com.jocmp.basil data class EditFeedForm( val feedID: String, - val name: String, + val title: String, val folderTitles: List = emptyList() ) diff --git a/basil/src/main/java/com/jocmp/basil/Feed.kt b/basil/src/main/java/com/jocmp/basil/Feed.kt index 45b5d7bf..95afb6a9 100644 --- a/basil/src/main/java/com/jocmp/basil/Feed.kt +++ b/basil/src/main/java/com/jocmp/basil/Feed.kt @@ -1,13 +1,12 @@ package com.jocmp.basil import kotlinx.serialization.Serializable -import java.net.URLEncoder @Serializable data class Feed( val id: String, val subscriptionID: String, - val name: String, + val title: String, val feedURL: String, val siteURL: String = "", val folderName: String = "", diff --git a/basil/src/main/java/com/jocmp/basil/accounts/FeedOPMLExt.kt b/basil/src/main/java/com/jocmp/basil/accounts/FeedOPMLExt.kt index a15007e9..e820069b 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/FeedOPMLExt.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/FeedOPMLExt.kt @@ -7,7 +7,7 @@ import com.jocmp.basil.common.prepending internal fun Feed.asOPML(indentLevel: Int): String { val parsedSiteURL = siteURL.escapingSpecialXMLCharacters val parsedFeedURL = feedURL.escapingSpecialXMLCharacters - val parsedName = name.escapingSpecialXMLCharacters + val parsedName = title.escapingSpecialXMLCharacters val opml = "\n" diff --git a/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountDelegate.kt b/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountDelegate.kt index 9e69c560..d5119cba 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountDelegate.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountDelegate.kt @@ -1,67 +1,124 @@ package com.jocmp.basil.accounts +import com.jocmp.basil.AccountDelegate import com.jocmp.basil.Feed +import com.jocmp.basil.common.host import com.jocmp.basil.common.nowUTC import com.jocmp.basil.common.toDateTime -import com.jocmp.basil.common.toDateTimeFromSeconds +import com.jocmp.basil.common.withResult import com.jocmp.basil.db.Database import com.jocmp.basil.persistence.ArticleRecords import com.jocmp.basil.persistence.FeedRecords +import com.jocmp.basil.persistence.TaggingRecords import com.jocmp.feedbinclient.CreateSubscriptionRequest +import com.jocmp.feedbinclient.CreateTaggingRequest import com.jocmp.feedbinclient.Entry import com.jocmp.feedbinclient.Feedbin import com.jocmp.feedbinclient.Icon import com.jocmp.feedbinclient.StarredEntriesRequest import com.jocmp.feedbinclient.Subscription import com.jocmp.feedbinclient.UnreadEntriesRequest +import com.jocmp.feedbinclient.UpdateSubscriptionRequest import com.jocmp.feedbinclient.pagingInfo import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import retrofit2.Response -import java.net.MalformedURLException -import java.net.URL import java.time.ZonedDateTime internal class FeedbinAccountDelegate( private val database: Database, private val feedbin: Feedbin -) { +) : AccountDelegate { class FeedNotFound : Exception() class SaveFeedFailure : Exception() private val articleRecords = ArticleRecords(database) private val feedRecords = FeedRecords(database) + private val taggingRecords = TaggingRecords(database) - fun fetchAll(feed: Feed): List { - return emptyList() - } - - suspend fun markRead(articleIDs: List) { + override suspend fun markRead(articleIDs: List) { val entryIDs = articleIDs.map { it.toLong() } feedbin.deleteUnreadEntries(UnreadEntriesRequest(unread_entries = entryIDs)) } - suspend fun markUnread(articleIDs: List) { + override suspend fun markUnread(articleIDs: List) { val entryIDs = articleIDs.map { it.toLong() } feedbin.createUnreadEntries(UnreadEntriesRequest(unread_entries = entryIDs)) } - suspend fun addStar(articleIDs: List) { + override suspend fun updateFeed( + feed: Feed, + title: String, + folderTitles: List + ): Result { + if (title != feed.title) { + feedRecords.updateTitle(feed = feed, title = title) + + feedbin.updateSubscription( + subscriptionID = feed.subscriptionID, + body = UpdateSubscriptionRequest(title = title) + ) + } + + val taggingIDsToDelete = taggingRecords.findFeedTaggingsToDelete( + feed = feed, + excludedTaggingNames = folderTitles + ) + + folderTitles.forEach { folderTitle -> + val request = CreateTaggingRequest(feed_id = feed.id, name = folderTitle) + + withResult(feedbin.createTagging(request)) { tagging -> + taggingRecords.upsert( + id = tagging.id, + feedID = tagging.feed_id.toString(), + name = tagging.name + ) + } + } + + taggingIDsToDelete.forEach { taggingID -> + val result = feedbin.deleteTagging(taggingID = taggingID.toString()) + + if (result.isSuccessful) { + database.taggingsQueries.deleteTagging(taggingID) + } + } + + val updatedFeed = feedRecords.findBy(feed.id) + + return if (updatedFeed != null) { + Result.success(updatedFeed) + } else { + Result.failure(Throwable("Feed not found")) + } + } + + override suspend fun removeFeed(feedID: String) { + val feed = feedRecords.findBy(feedID) ?: return + + val result = feedbin.deleteSubscription(subscriptionID = feed.subscriptionID) + + if (result.isSuccessful) { + feedRecords.removeFeed(feedID = feedID) + } + } + + override suspend fun addStar(articleIDs: List) { val entryIDs = articleIDs.map { it.toLong() } feedbin.createStarredEntries(StarredEntriesRequest(starred_entries = entryIDs)) } - suspend fun removeStar(articleIDs: List) { + override suspend fun removeStar(articleIDs: List) { val entryIDs = articleIDs.map { it.toLong() } feedbin.deleteStarredEntries(StarredEntriesRequest(starred_entries = entryIDs)) } - suspend fun addFeed(url: String): Result { + override suspend fun addFeed(url: String): Result { val response = feedbin.createSubscription(CreateSubscriptionRequest(feed_url = url)) val subscription = response.body() val errorBody = response.errorBody()?.string() @@ -92,8 +149,8 @@ internal class FeedbinAccountDelegate( } } - suspend fun refresh() { - val since = maxUpdatedAt() + override suspend fun refresh() { + val since = articleRecords.maxUpdatedAt() refreshFeeds() refreshTaggings() @@ -130,7 +187,7 @@ internal class FeedbinAccountDelegate( } private fun upsertFeed(subscription: Subscription, icons: List) { - val icon = icons.find { it.host == host(subscription) } + val icon = icons.find { it.host == subscription.host } database.feedsQueries.upsert( id = subscription.feed_id.toString(), @@ -144,12 +201,16 @@ internal class FeedbinAccountDelegate( private suspend fun refreshTaggings() { withResult(feedbin.taggings()) { taggings -> - taggings.forEach { tagging -> - database.taggingsQueries.upsert( - id = tagging.id, - feed_id = tagging.feed_id.toString(), - name = tagging.name, - ) + database.transaction { + taggings.forEach { tagging -> + database.taggingsQueries.upsert( + id = tagging.id, + feed_id = tagging.feed_id.toString(), + name = tagging.name, + ) + } + + database.taggingsQueries.deleteOrphanedTags(excludedIDs = taggings.map { it.id }) } } } @@ -230,38 +291,7 @@ internal class FeedbinAccountDelegate( return result } - /** Date in UTC */ - private fun maxUpdatedAt(): String { - val max = database.articlesQueries.lastUpdatedAt().executeAsOne().MAX - - max ?: return cutoffDate().toString() - - return max.toDateTimeFromSeconds.toString() - } - companion object { const val MAX_ENTRY_LIMIT = 100 } } - -private fun cutoffDate(): ZonedDateTime { - return nowUTC().minusMonths(3) -} - -private fun host(subscription: Subscription): String? { - return try { - URL(subscription.site_url).host - } catch (e: MalformedURLException) { - null - } -} - -private fun withResult(response: Response, handler: (result: T) -> Unit) { - val result = response.body() - - if (!response.isSuccessful || result == null) { - return - } - - handler(result) -} diff --git a/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountExt.kt b/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountExt.kt index e4ab02a3..7626191d 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountExt.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/FeedbinAccountExt.kt @@ -1,19 +1,21 @@ package com.jocmp.basil.accounts -import com.jocmp.basil.Account +import com.jocmp.basil.AccountPreferences import com.jocmp.feedbinclient.BasicAuthInterceptor import com.jocmp.feedbinclient.Feedbin import okhttp3.Cache import okhttp3.Credentials import okhttp3.OkHttpClient import java.io.File +import java.net.URI internal fun Feedbin.Companion.forAccount( - account: Account, + path: URI, + preferences: AccountPreferences ): Feedbin { val basicAuthInterceptor = BasicAuthInterceptor { - val username = account.preferences.username.get() - val password = account.preferences.password.get() + val username = preferences.username.get() + val password = preferences.password.get() Credentials.basic(username, password) } @@ -22,7 +24,7 @@ internal fun Feedbin.Companion.forAccount( .addInterceptor(basicAuthInterceptor) .cache( Cache( - directory = File(File(account.path), "http_cache"), + directory = File(File(path), "http_cache"), maxSize = 50L * 1024L * 1024L // 50 MiB ) ) diff --git a/basil/src/main/java/com/jocmp/basil/common/SubscriptionHostExt.kt b/basil/src/main/java/com/jocmp/basil/common/SubscriptionHostExt.kt new file mode 100644 index 00000000..b1467d2b --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/common/SubscriptionHostExt.kt @@ -0,0 +1,14 @@ +package com.jocmp.basil.common + +import com.jocmp.feedbinclient.Subscription +import java.net.MalformedURLException +import java.net.URL + +internal val Subscription.host: String? + get() { + return try { + URL(site_url).host + } catch (e: MalformedURLException) { + null + } + } diff --git a/basil/src/main/java/com/jocmp/basil/common/WithResult.kt b/basil/src/main/java/com/jocmp/basil/common/WithResult.kt new file mode 100644 index 00000000..cfb7af75 --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/common/WithResult.kt @@ -0,0 +1,13 @@ +package com.jocmp.basil.common + +import retrofit2.Response + +internal fun withResult(response: Response, handler: (result: T) -> Unit) { + val result = response.body() + + if (!response.isSuccessful || result == null) { + return + } + + handler(result) +} diff --git a/basil/src/main/java/com/jocmp/basil/opml/FeedOutlineExt.kt b/basil/src/main/java/com/jocmp/basil/opml/FeedOutlineExt.kt index daa78fa0..7a1fc2ff 100644 --- a/basil/src/main/java/com/jocmp/basil/opml/FeedOutlineExt.kt +++ b/basil/src/main/java/com/jocmp/basil/opml/FeedOutlineExt.kt @@ -13,7 +13,7 @@ internal fun OPMLFeed.asFeed(feeds: Map = mapOf()): Feed? { return Feed( id = feed.id, subscriptionID = feed.subscription_id, - name = title ?: "", + title = title ?: "", feedURL = xmlUrl ?: "" ) } diff --git a/basil/src/main/java/com/jocmp/basil/persistence/ArticlePagerFactory.kt b/basil/src/main/java/com/jocmp/basil/persistence/ArticlePagerFactory.kt index 5a2c0287..0f380c84 100644 --- a/basil/src/main/java/com/jocmp/basil/persistence/ArticlePagerFactory.kt +++ b/basil/src/main/java/com/jocmp/basil/persistence/ArticlePagerFactory.kt @@ -1,11 +1,14 @@ package com.jocmp.basil.persistence import androidx.paging.PagingSource +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.paging3.QueryPagingSource import com.jocmp.basil.Article import com.jocmp.basil.ArticleFilter import com.jocmp.basil.db.Database import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import java.time.OffsetDateTime class ArticlePagerFactory(private val database: Database) { @@ -48,7 +51,7 @@ class ArticlePagerFactory(private val database: Database) { filter: ArticleFilter.Feeds, since: OffsetDateTime ): PagingSource { - val feedIDs = listOf(filter.feed.id) + val feedIDs = listOf(filter.feedID) return feedsSource(feedIDs, filter, since) } @@ -57,7 +60,10 @@ class ArticlePagerFactory(private val database: Database) { filter: ArticleFilter.Folders, since: OffsetDateTime ): PagingSource { - val feedIDs = filter.folder.feeds.map { it.id } + val feedIDs = database + .taggingsQueries + .findFeedIDs(folderTitle = filter.folderTitle) + .executeAsList() return feedsSource(feedIDs, filter, since) } diff --git a/basil/src/main/java/com/jocmp/basil/persistence/ArticleRecords.kt b/basil/src/main/java/com/jocmp/basil/persistence/ArticleRecords.kt index 27cef721..9c00bb98 100644 --- a/basil/src/main/java/com/jocmp/basil/persistence/ArticleRecords.kt +++ b/basil/src/main/java/com/jocmp/basil/persistence/ArticleRecords.kt @@ -1,12 +1,12 @@ package com.jocmp.basil.persistence -import android.util.Log import app.cash.sqldelight.Query import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList import com.jocmp.basil.Article import com.jocmp.basil.ArticleStatus import com.jocmp.basil.common.nowUTC +import com.jocmp.basil.common.toDateTimeFromSeconds import com.jocmp.basil.db.Database import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.map import java.time.OffsetDateTime import java.time.ZonedDateTime -class ArticleRecords internal constructor( +internal class ArticleRecords internal constructor( private val database: Database ) { val byStatus = ByStatus(database) @@ -119,6 +119,15 @@ class ArticleRecords internal constructor( } } + /** Date in UTC */ + fun maxUpdatedAt(): String { + val max = database.articlesQueries.lastUpdatedAt().executeAsOne().MAX + + max ?: return cutoffDate().toString() + + return max.toDateTimeFromSeconds.toString() + } + class ByFeed(private val database: Database) { fun all( feedIDs: List, @@ -194,3 +203,7 @@ private fun mapLastRead(read: Boolean?, value: OffsetDateTime?): Long? { return null } + +private fun cutoffDate(): ZonedDateTime { + return nowUTC().minusMonths(3) +} diff --git a/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt b/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt index 2cda6ce8..b6e6a94f 100644 --- a/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt +++ b/basil/src/main/java/com/jocmp/basil/persistence/FeedRecords.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlin.coroutines.coroutineContext -internal class FeedRecords(val database: Database) { +internal class FeedRecords(private val database: Database) { suspend fun findBy(id: String): Feed? { return database.feedsQueries.findBy(id, mapper = ::feedMapper) .asFlow() @@ -20,6 +20,33 @@ internal class FeedRecords(val database: Database) { .firstOrNull() } + suspend fun upsert( + feedID: String, + subscriptionID: String, + title: String, + feedURL: String, + siteURL: String?, + faviconURL: String? + ): Feed? { + database.feedsQueries.upsert( + id = feedID, + subscription_id = subscriptionID, + title = title, + feed_url = feedURL, + site_url = siteURL, + favicon_url = faviconURL, + ) + + return findBy(feedID) + } + + fun updateTitle(feed: Feed, title: String) { + database.feedsQueries.updateName( + title = title, + feedID = feed.id, + ) + } + suspend fun findFolder(title: String): Folder? { val feeds = database.feedsQueries.findByFolder(title, mapper = ::feedMapper) .asFlow() @@ -57,7 +84,7 @@ internal class FeedRecords(val database: Database) { return Feed( id = id, subscriptionID = subscriptionID, - name = title, + title = title, feedURL = feedURL, siteURL = siteURL ?: "", faviconURL = faviconURL, @@ -66,10 +93,3 @@ internal class FeedRecords(val database: Database) { ) } } - -private const val TOP_LEVEL_KEY = "top-level" - -data class AllFeeds( - val topLevelFeeds: List = emptyList(), - val folders: List = emptyList(), -) diff --git a/basil/src/main/java/com/jocmp/basil/persistence/TaggingRecords.kt b/basil/src/main/java/com/jocmp/basil/persistence/TaggingRecords.kt new file mode 100644 index 00000000..17426f40 --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/persistence/TaggingRecords.kt @@ -0,0 +1,35 @@ +package com.jocmp.basil.persistence + +import com.jocmp.basil.Feed +import com.jocmp.basil.db.Database + +internal class TaggingRecords( + private val database: Database +) { + fun findFeedTaggingsToDelete( + feed: Feed, + excludedTaggingNames: List + ): List { + return database + .taggingsQueries + .findFeedTaggingsToDelete( + feedID = feed.id, + excludedNames = excludedTaggingNames + ) + .executeAsList() + } + + fun upsert( + id: Long, + feedID: String, + name: String, + ) { + return database + .taggingsQueries + .upsert( + id = id, + feed_id = feedID, + name = name + ) + } +} diff --git a/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq b/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq index 0b032d49..aaaa9532 100644 --- a/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq +++ b/basil/src/main/sqldelight/com/jocmp/basil/db/feeds.sq @@ -40,6 +40,9 @@ VALUES ( :favicon_url ); +updateName: +UPDATE feeds SET title = :title WHERE feeds.id = :feedID; + delete { DELETE FROM article_statuses WHERE article_statuses.article_id IN ( SELECT id diff --git a/basil/src/main/sqldelight/com/jocmp/basil/db/taggings.sq b/basil/src/main/sqldelight/com/jocmp/basil/db/taggings.sq index b341aa31..7e638509 100644 --- a/basil/src/main/sqldelight/com/jocmp/basil/db/taggings.sq +++ b/basil/src/main/sqldelight/com/jocmp/basil/db/taggings.sq @@ -9,3 +9,20 @@ VALUES ( :feed_id, :name ); + +deleteOrphanedTags: +DELETE FROM taggings WHERE id NOT IN :excludedIDs; + +deleteTagging: +DELETE FROM taggings WHERE id = :id; + +findFeedTaggingsToDelete: +SELECT id +FROM taggings +WHERE taggings.feed_id = :feedID +AND taggings.name NOT IN :excludedNames; + +findFeedIDs: +SELECT feed_id +FROM taggings +WHERE taggings.name = :folderTitle; diff --git a/basil/src/test/java/com/jocmp/basil/AccountTest.kt b/basil/src/test/java/com/jocmp/basil/AccountTest.kt deleted file mode 100644 index 78ac26df..00000000 --- a/basil/src/test/java/com/jocmp/basil/AccountTest.kt +++ /dev/null @@ -1,432 +0,0 @@ -package com.jocmp.basil - -class AccountTest { -// @JvmField -// @Rule -// val folder = TemporaryFolder() -// -// private lateinit var database: Database -// -// private val defaultEntry = AddFeedForm( -// url = URL(THE_VERGE_URL), -// name = "The Verge", -// folderTitles = listOf() -// ) -// -// private val feedFinder = TestFeedFinder( -// mapOf( -// THE_VERGE_URL to TheVergeFeed(), -// ARS_TECHNICA_URL to ArsTechnicaFeed() -// ) -// ) -// -// @Before -// fun setup() { -// mockkConstructor(aFeedbinAccountDelegate::class) -// -// coEvery { -// anyConstructed().fetchAll(any()) -// } returns emptyList() -// -// database = InMemoryDatabaseProvider.build("777") -// } -// -// private fun buildAccount(id: String = "777", path: File = folder.newFile()): Account { -// return Account( -// id = id, -// path = path.toURI(), -// database = database, -// preferences = AccountPreferences(InMemoryDataStore()), -// feedFinder = feedFinder -// ) -// } -// -// @Test -// fun constructor_loadsExistingFeeds() = runTest { -// val accountPath = folder.newFile() -// val accountID = "777" -// -// val previousInstance = buildAccount(id = accountID, path = accountPath) -// previousInstance.addFeed( -// AddFeedForm( -// url = URL(THE_VERGE_URL), -// name = "The Verge", -// folderTitles = listOf("Test Title"), -// ) -// ) -// -// val account = buildAccount(id = accountID, path = accountPath) -// val accountTitle = account.folders.first().first().title -// -// assertEquals(expected = "Test Title", actual = accountTitle) -// assertEquals(expected = 1, actual = account.flattenedFeeds.size) -// } -// -// @Test -// fun addFeed_singleTopLevelFeed() = runTest { -// val accountPath = folder.newFile() -// val account = buildAccount(id = "777", path = accountPath) -// val entry = AddFeedForm( -// url = URL("https://theverge.com/rss/index.xml"), -// name = "The Verge", -// folderTitles = listOf(), -// ) -// -// account.addFeed(entry) -// -// assertEquals(expected = account.feeds.first().size, actual = 1) -// assertEquals(expected = account.folders.first().size, actual = 0) -// -// val feed = account.feeds.first().first() -// assertEquals(expected = entry.name, actual = entry.name) -// assertEquals(expected = entry.url.toString(), actual = feed.feedURL) -// } -// -// @Test -// fun addFeed_newFolder() { -// val accountPath = folder.newFile() -// val account = buildAccount(id = "777", path = accountPath) -// val entry = AddFeedForm( -// url = URL("https://theverge.com/rss/index.xml"), -// name = "The Verge", -// folderTitles = listOf("Tech"), -// ) -// -// runBlocking { account.addFeed(entry) } -// -// assertEquals(expected = account.topLevelFeeds.size, actual = 0) -// assertEquals(expected = account.folders.size, actual = 1) -// -// val feed = account.folders.first().feeds.first() -// assertEquals(expected = entry.name, actual = entry.name) -// assertEquals(expected = entry.url.toString(), actual = feed.feedURL) -// } -// -// @Test -// fun addFeed_existingFolders() { -// val accountPath = folder.newFile() -// val account = buildAccount(id = "777", path = accountPath) -// runBlocking { account.addFolder("Tech") } -// -// val entry = AddFeedForm( -// url = URL("https://theverge.com/rss/index.xml"), -// name = "The Verge", -// folderTitles = listOf("Tech"), -// ) -// -// runBlocking { account.addFeed(entry) } -// -// assertEquals(expected = account.topLevelFeeds.size, actual = 0) -// assertEquals(expected = account.folders.size, actual = 1) -// -// val feed = account.folders.first().feeds.first() -// assertEquals(expected = entry.name, actual = feed.name) -// assertEquals(expected = entry.url.toString(), actual = feed.feedURL) -// } -// -// @Test -// fun addFeed_multipleFolders() { -// val accountPath = folder.newFile() -// val account = buildAccount(id = "777", path = accountPath) -// runBlocking { account.addFolder("Tech") } -// -// val entry = AddFeedForm( -// url = URL("https://theverge.com/rss/index.xml"), -// name = "The Verge", -// folderTitles = listOf("Tech", "Culture"), -// ) -// -// runBlocking { account.addFeed(entry) } -// -// assertEquals(expected = account.topLevelFeeds.size, actual = 0) -// assertEquals(expected = account.folders.size, actual = 2) -// -// val techFeed = account.folders.first().feeds.first() -// val cultureFeed = account.folders.first().feeds.first() -// assertEquals(expected = entry.name, actual = techFeed.name) -// assertEquals(expected = entry.url.toString(), actual = techFeed.feedURL) -// assertEquals(techFeed, cultureFeed) -// } -// -// @Test -// fun removeFeed_topLevelFeed() { -// val account = buildAccount() -// runBlocking { -// account.addFeed( -// AddFeedForm( -// url = URL("https://theverge.com/rss/index.xml"), -// name = "The Verge", -// folderTitles = listOf(), -// ) -// ) -// } -// -// val feed = account.topLevelFeeds.find { it.name == "The Verge" }!! -// -// assertEquals(expected = 1, account.topLevelFeeds.size) -// -// runBlocking { account.removeFeed(feedID = feed.id) } -// -// assertEquals(expected = 0, account.topLevelFeeds.size) -// } -// -// @Test -// fun editFeed_topLevelFeed() { -// val account = buildAccount() -// val feed = runBlocking { account.addFeed(defaultEntry) }.getOrNull()!! -// -// val feedName = "The Verge Mobile" -// -// val editedFeed = runBlocking { -// account.editFeed(EditFeedForm(feedID = feed.id, name = feedName)) -// }.getOrNull()!! -// -// assertEquals(expected = feedName, actual = editedFeed.name) -// } -// -// @Test -// fun editFeed_nestedFeed() { -// val account = buildAccount() -// val feed = runBlocking { -// account.addFeed(defaultEntry.copy(folderTitles = listOf("Tech"))) -// }.getOrNull()!! -// -// val feedName = "The Verge Mobile" -// -// runBlocking { -// account.editFeed( -// EditFeedForm( -// feedID = feed.id, -// name = feedName, -// folderTitles = listOf("Tech") -// ) -// ) -// } -// -// val renamedFeed = account.folders.first().feeds.first() -// -// assertEquals(expected = feedName, actual = renamedFeed.name) -// } -// -// @Test -// fun editFeed_movedFeedFromTopLevelToFolder() { -// val account = buildAccount() -// val feed = runBlocking { -// account.addFeed(defaultEntry) -// }.getOrNull()!! -// -// val feedName = "The Verge Mobile" -// -// runBlocking { -// account.editFeed( -// EditFeedForm( -// feedID = feed.id, -// name = feedName, -// folderTitles = listOf("Tech") -// ) -// ) -// } -// -// assertTrue(account.topLevelFeeds.isEmpty()) -// assertEquals(expected = 1, actual = account.folders.size) -// -// val renamedFeed = account.folders.first().feeds.first() -// -// assertEquals(expected = feedName, actual = renamedFeed.name) -// } -// -// @Test -// fun editFeed_movedFeedFromFolderToTopLevel() { -// val account = buildAccount() -// -// val feed = runBlocking { -// account.addFeed(defaultEntry) -// }.getOrNull()!! -// -// val otherFeed = runBlocking { -// account.addFeed( -// AddFeedForm( -// url = URL(ARS_TECHNICA_URL), -// name = "Ars Technica", -// folderTitles = listOf("Tech") -// ) -// ) -// }.getOrNull()!! -// -// val feedName = "The Verge" -// -// runBlocking { -// account.editFeed( -// EditFeedForm( -// feedID = feed.id, -// name = feedName, -// folderTitles = listOf() -// ) -// ) -// } -// -// assertEquals(expected = 1, actual = account.topLevelFeeds.size) -// assertEquals(expected = 1, actual = account.folders.size) -// -// val movedFeed = account.topLevelFeeds.first() -// val existingFeed = account.folders.first().feeds.first() -// -// assertEquals(expected = feedName, actual = movedFeed.name) -// assertEquals(expected = otherFeed.name, actual = existingFeed.name) -// } -// -// @Test -// fun editFeed_movedFeedFromFolderToTopLevelWithOtherFeeds() { -// val account = buildAccount() -// val feed = runBlocking { -// account.addFeed(defaultEntry) -// }.getOrNull()!! -// -// val feedName = "The Verge Mobile" -// -// runBlocking { -// account.editFeed( -// EditFeedForm( -// feedID = feed.id, -// name = feedName, -// folderTitles = listOf("Tech") -// ) -// ) -// } -// -// assertTrue(account.topLevelFeeds.isEmpty()) -// assertEquals(expected = 1, actual = account.folders.size) -// -// val renamedFeed = account.folders.first().feeds.first() -// -// assertEquals(expected = feedName, actual = renamedFeed.name) -// } -// -// @Test -// fun editFolder() { -// val account = buildAccount() -// -// runBlocking { -// account.addFeed(defaultEntry.copy(folderTitles = listOf("Tech"))) -// } -// -// val folderTitle = "Tech & Culture" -// -// runBlocking { -// account.editFolder(form = EditFolderForm(existingTitle = "Tech", title = folderTitle)) -// } -// -// assertEquals(expected = 1, actual = account.folders.size) -// -// val renamedFolder = account.folders.first() -// -// assertEquals(expected = folderTitle, actual = renamedFolder.title) -// } -// -// @Test -// fun removeFolder() { -// val account = buildAccount() -// -// val feed = runBlocking { -// account.addFeed(defaultEntry.copy(folderTitles = listOf("Tech"))) -// }.getOrNull()!! -// -// runBlocking { -// account.removeFolder(title = "Tech") -// } -// -// assertEquals(expected = 0, actual = account.folders.size) -// assertEquals(expected = 1, actual = account.topLevelFeeds.size) -// -// val movedFeed = account.topLevelFeeds.first() -// -// assertEquals(expected = movedFeed.id, actual = feed.id) -// } -// -// @Test -// fun removeFolder_feedInMultipleFolders() { -// val account = buildAccount() -// -// val feed = runBlocking { -// account.addFeed(defaultEntry.copy(folderTitles = listOf("Tech", "Culture"))) -// }.getOrNull()!! -// -// runBlocking { -// account.removeFolder(title = "Tech") -// } -// -// assertEquals(expected = 1, actual = account.folders.size) -// assertEquals(expected = 0, actual = account.topLevelFeeds.size) -// -// val otherFolder = account.folders.first() -// val movedFeed = otherFolder.feeds.first() -// -// assertEquals(expected = "Culture", otherFolder.title) -// assertEquals(expected = movedFeed.id, actual = feed.id) -// } -// -// @Test -// fun findFeed_topLevelFeed() { -// val account = buildAccount(id = "777", path = folder.newFile()) -// -// val entry = AddFeedForm( -// url = URL("https://theverge.com/rss/index.xml"), -// name = "The Verge", -// folderTitles = emptyList() -// ) -// -// val feedID = runBlocking { account.addFeed(entry) }.getOrNull()!!.id -// -// val result = account.findFeed(feedID)!! -// -// assertEquals(expected = feedID, actual = result.id) -// } -// -// @Test -// fun findFeed_nestedFeed() { -// val account = buildAccount(id = "777", path = folder.newFile()) -// -// val entry = defaultEntry.copy(folderTitles = listOf("Tech", "Culture")) -// -// val feedID = runBlocking { account.addFeed(entry) }.getOrNull()!!.id -// -// val result = account.findFeed(feedID)!! -// -// assertEquals(expected = feedID, actual = result.id) -// } -// -// @Test -// fun findFeed_feedDoesNotExist() { -// val account = buildAccount(id = "777", path = folder.newFile()) -// -// val result = account.findFeed("missing") -// -// assertNull(result) -// } -// -// @Test -// fun findFolder_existingFolder() { -// val account = buildAccount(id = "777", path = folder.newFile()) -// -// val entry = AddFeedForm( -// url = URL("https://theverge.com/rss/index.xml"), -// name = "The Verge", -// folderTitles = listOf("Tech", "Culture") -// ) -// -// runBlocking { account.addFeed(entry) } -// -// val result = account.findFolder("Tech")!! -// -// assertEquals(expected = "Tech", actual = result.title) -// } -// -// @Test -// fun findFolder_folderDoesNotExist() { -// val account = buildAccount(id = "777", path = folder.newFile()) -// -// val result = account.findFolder("Tech") -// -// assertNull(result) -// } -} diff --git a/basil/src/test/java/com/jocmp/basil/accounts/FeedbinAccountDelegateTest.kt b/basil/src/test/java/com/jocmp/basil/accounts/FeedbinAccountDelegateTest.kt index 1f8e1a45..66b6b3d1 100644 --- a/basil/src/test/java/com/jocmp/basil/accounts/FeedbinAccountDelegateTest.kt +++ b/basil/src/test/java/com/jocmp/basil/accounts/FeedbinAccountDelegateTest.kt @@ -12,6 +12,7 @@ import com.jocmp.feedbinclient.StarredEntriesRequest import com.jocmp.feedbinclient.Subscription import com.jocmp.feedbinclient.Tagging import com.jocmp.feedbinclient.UnreadEntriesRequest +import com.jocmp.feedbinclient.UpdateSubscriptionRequest import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -251,7 +252,7 @@ class FeedbinAccountDelegateTest { assertEquals( expected = "Ed Zitron", - actual = feed.name + actual = feed.title ) } @@ -324,7 +325,34 @@ class FeedbinAccountDelegateTest { } @Test - fun fetchWithPagination() { + fun updateFeed_modifyTitle() = runTest { + val delegate = FeedbinAccountDelegate(database, feedbin) + val feed = feedFixture.create() + + val subscription = Subscription( + id = feed.subscriptionID.toLong(), + created_at = "2024-01-30T19:42:44.851265Z", + feed_id = feed.id.toLong(), + title = feed.title, + feed_url = feed.feedURL, + site_url = feed.siteURL + ) + + val feedTitle = "The Verge Mobile Podcast" + + coEvery { + feedbin.updateSubscription( + subscriptionID = feed.subscriptionID, + body = UpdateSubscriptionRequest(title = feedTitle) + ) + }.returns(Response.success(subscription)) + + val updated = delegate.updateFeed( + feed = feed, + title = feedTitle, + folderTitles = emptyList() + ).getOrThrow() + assertEquals(expected = feedTitle, actual = updated.title) } } diff --git a/basil/src/test/java/com/jocmp/basil/fixtures/AccountFixture.kt b/basil/src/test/java/com/jocmp/basil/fixtures/AccountFixture.kt index 8b80f144..d18f6f7c 100644 --- a/basil/src/test/java/com/jocmp/basil/fixtures/AccountFixture.kt +++ b/basil/src/test/java/com/jocmp/basil/fixtures/AccountFixture.kt @@ -1,24 +1,28 @@ package com.jocmp.basil.fixtures import com.jocmp.basil.Account +import com.jocmp.basil.AccountDelegate import com.jocmp.basil.AccountPreferences import com.jocmp.basil.InMemoryDataStore import com.jocmp.basil.InMemoryDatabaseProvider import com.jocmp.basil.RandomUUID +import com.jocmp.basil.db.Database +import io.mockk.mockk import org.junit.rules.TemporaryFolder object AccountFixture { fun create( id: String = RandomUUID.generate(), parentFolder: TemporaryFolder, + database: Database = InMemoryDatabaseProvider.build(id), + accountDelegate: AccountDelegate = mockk() ): Account { - val database = InMemoryDatabaseProvider.build(id) - return Account( id = id, path = parentFolder.newFile().toURI(), database = database, preferences = AccountPreferences(InMemoryDataStore()), + delegate = accountDelegate ) } } diff --git a/basil/src/test/java/com/jocmp/basil/fixtures/ArticleFixture.kt b/basil/src/test/java/com/jocmp/basil/fixtures/ArticleFixture.kt index 3af59cbc..01b2d5d3 100644 --- a/basil/src/test/java/com/jocmp/basil/fixtures/ArticleFixture.kt +++ b/basil/src/test/java/com/jocmp/basil/fixtures/ArticleFixture.kt @@ -1,11 +1,11 @@ package com.jocmp.basil.fixtures import com.jocmp.basil.Article +import com.jocmp.basil.Feed import com.jocmp.basil.RandomUUID -import com.jocmp.basil.persistence.articleMapper import com.jocmp.basil.common.nowUTCInSeconds import com.jocmp.basil.db.Database -import com.jocmp.basil.db.Feeds as DBFeed +import com.jocmp.basil.persistence.articleMapper class ArticleFixture(private val database: Database) { private val feedFixture = FeedFixture(database) @@ -13,7 +13,7 @@ class ArticleFixture(private val database: Database) { fun create( id: String = RandomUUID.generate(), title: String = "Test Title", - feed: DBFeed = feedFixture.create(feedURL = "https://example.com/${RandomUUID.generate()}"), + feed: Feed = feedFixture.create(feedURL = "https://example.com/${RandomUUID.generate()}"), publishedAt: Long = nowUTCInSeconds() ): Article { database.transaction { diff --git a/basil/src/test/java/com/jocmp/basil/fixtures/FeedFixture.kt b/basil/src/test/java/com/jocmp/basil/fixtures/FeedFixture.kt index 105cfceb..805da37a 100644 --- a/basil/src/test/java/com/jocmp/basil/fixtures/FeedFixture.kt +++ b/basil/src/test/java/com/jocmp/basil/fixtures/FeedFixture.kt @@ -1,23 +1,30 @@ package com.jocmp.basil.fixtures +import com.jocmp.basil.Feed import com.jocmp.basil.RandomUUID import com.jocmp.basil.db.Database +import com.jocmp.basil.persistence.FeedRecords +import com.jocmp.basil.persistence.listMapper +import kotlinx.coroutines.runBlocking +import java.security.SecureRandom import com.jocmp.basil.db.Feeds as DBFeed class FeedFixture(private val database: Database) { + private val records = FeedRecords(database) + fun create( - feedID: String = RandomUUID.generate(), + feedID: String = randomID(), feedURL: String = "https://example.com" - ): DBFeed { - database.feedsQueries.upsert( - id = feedID, - subscription_id = RandomUUID.generate(), + ): Feed = runBlocking { + records.upsert( + feedID = feedID, + subscriptionID = randomID(), title = "My Feed", - feed_url = feedURL, - site_url = feedURL, - favicon_url = null - ) - - return database.feedsQueries.findBy(id = feedID).executeAsOne() + feedURL = feedURL, + siteURL = feedURL, + faviconURL = null, + )!! } + + private fun randomID() = SecureRandom.getInstanceStrong().nextInt().toString() } diff --git a/feedbinclient/src/main/java/com/jocmp/feedbinclient/CreateTaggingRequest.kt b/feedbinclient/src/main/java/com/jocmp/feedbinclient/CreateTaggingRequest.kt new file mode 100644 index 00000000..f02188c7 --- /dev/null +++ b/feedbinclient/src/main/java/com/jocmp/feedbinclient/CreateTaggingRequest.kt @@ -0,0 +1,9 @@ +package com.jocmp.feedbinclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CreateTaggingRequest( + val feed_id: String, + val name: String, +) diff --git a/feedbinclient/src/main/java/com/jocmp/feedbinclient/Feedbin.kt b/feedbinclient/src/main/java/com/jocmp/feedbinclient/Feedbin.kt index 8e2ffe64..dc2803d7 100644 --- a/feedbinclient/src/main/java/com/jocmp/feedbinclient/Feedbin.kt +++ b/feedbinclient/src/main/java/com/jocmp/feedbinclient/Feedbin.kt @@ -3,16 +3,19 @@ package com.jocmp.feedbinclient import com.squareup.moshi.Moshi import okhttp3.Credentials import okhttp3.OkHttpClient -import retrofit2.CallAdapter import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.create import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.HTTP import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.PATCH import retrofit2.http.POST +import retrofit2.http.Path import retrofit2.http.Query interface Feedbin { @@ -32,15 +35,31 @@ interface Feedbin { @GET("v2/icons.json") suspend fun icons(): Response> + @Headers("Cache-Control: no-cache") @GET("v2/subscriptions.json") suspend fun subscriptions(): Response> @POST("v2/subscriptions.json") suspend fun createSubscription(@Body body: CreateSubscriptionRequest): Response + @DELETE("v2/subscriptions/{subscriptionID}.json") + suspend fun deleteSubscription(@Path("subscriptionID") subscriptionID: String): Response + + @PATCH("v2/subscriptions/{subscriptionID}.json") + suspend fun updateSubscription( + @Path("subscriptionID") subscriptionID: String, + @Body body: UpdateSubscriptionRequest + ): Response + @GET("v2/taggings.json") suspend fun taggings(): Response> + @POST("v2/taggings.json") + suspend fun createTagging(@Body body: CreateTaggingRequest): Response + + @DELETE("v2/taggings/{taggingID}.json") + suspend fun deleteTagging(@Path("taggingID") taggingID: String): Response + @GET("v2/starred_entries.json") suspend fun starredEntries(): Response> diff --git a/feedbinclient/src/main/java/com/jocmp/feedbinclient/UpdateSubscriptionRequest.kt b/feedbinclient/src/main/java/com/jocmp/feedbinclient/UpdateSubscriptionRequest.kt new file mode 100644 index 00000000..b583e159 --- /dev/null +++ b/feedbinclient/src/main/java/com/jocmp/feedbinclient/UpdateSubscriptionRequest.kt @@ -0,0 +1,8 @@ +package com.jocmp.feedbinclient + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UpdateSubscriptionRequest( + val title: String +) diff --git a/technotes/Log.md b/technotes/Log.md new file mode 100644 index 00000000..e335563c --- /dev/null +++ b/technotes/Log.md @@ -0,0 +1,14 @@ +# Log + +## 2024-04-07 + +1. For each added folder, call POST taggings + ```json + { + "feed_id": "123", + "name": "Test Folder" + } + ``` + +2. For each removed folder, call DELETE taggings +3. If feed name changed, call POST subscriptions