From 5d77d9373f704118e96ece4aaa22aa020f93da0e Mon Sep 17 00:00:00 2001 From: Daniel Yrovas Date: Mon, 6 May 2024 22:18:55 +1000 Subject: [PATCH] check if bookmark exists, and preview title & description --- .../yrovas/linklater/data/BookmarkMetadata.kt | 10 +++ .../linklater/data/remote/EmptyBookmarkAPI.kt | 5 ++ .../linklater/data/remote/LinkDingAPI.kt | 31 +++++-- .../yrovas/linklater/domain/BookmarkAPI.kt | 2 + .../ui/screens/SaveBookmarkScreen.kt | 55 +++++++----- .../ui/state/SaveBookmarkScreenState.kt | 90 ++++++++++++++++--- gradle/libs.versions.toml | 4 +- 7 files changed, 160 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/org/yrovas/linklater/data/BookmarkMetadata.kt diff --git a/app/src/main/java/org/yrovas/linklater/data/BookmarkMetadata.kt b/app/src/main/java/org/yrovas/linklater/data/BookmarkMetadata.kt new file mode 100644 index 0000000..b1097e2 --- /dev/null +++ b/app/src/main/java/org/yrovas/linklater/data/BookmarkMetadata.kt @@ -0,0 +1,10 @@ +package org.yrovas.linklater.data + +import kotlinx.serialization.Serializable + +@Serializable +data class BookmarkMetadata( + val url: String, + val title: String? = null, + val description: String? = null, +) diff --git a/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt b/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt index 6637ab1..c16f47f 100644 --- a/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt +++ b/app/src/main/java/org/yrovas/linklater/data/remote/EmptyBookmarkAPI.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.yrovas.linklater.data.Bookmark +import org.yrovas.linklater.data.BookmarkMetadata import org.yrovas.linklater.data.LocalBookmark import org.yrovas.linklater.domain.APIError import org.yrovas.linklater.domain.BookmarkAPI @@ -42,4 +43,8 @@ class EmptyBookmarkAPI : BookmarkAPI { override suspend fun getTags(page: Int): Res, APIError> { return Ok(emptyList()) } + + override suspend fun checkExists(url: String): Pair { + return null to null + } } diff --git a/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt b/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt index 9ce1f4b..485c7a0 100644 --- a/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt +++ b/app/src/main/java/org/yrovas/linklater/data/remote/LinkDingAPI.kt @@ -5,6 +5,7 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.header +import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +18,7 @@ import org.yrovas.linklater.AppScope import org.yrovas.linklater.checkBookmarkAPIToken import org.yrovas.linklater.checkURL import org.yrovas.linklater.data.Bookmark +import org.yrovas.linklater.data.BookmarkMetadata import org.yrovas.linklater.data.LocalBookmark import org.yrovas.linklater.domain.APIError import org.yrovas.linklater.domain.BookmarkAPI @@ -78,6 +80,7 @@ class LinkDingAPI( val response = client.get("${endpoint!!}/bookmarks/") { header("Authorization", "Token ${token!!}") if (page > 0) { +// parameter("offset", pageSize * page) url.parameters.append( "offset", (pageSize * page).toString() ) @@ -125,11 +128,29 @@ class LinkDingAPI( } } -// @Serializable -// data class BookmarkSavedResponse( -// val id: Long, -// val url: String, -// ) + override suspend fun checkExists(url: String): Pair { + if (!authProvided.value) return null to null + if (url.isBlank()) return null to null + + return try { + Log.d("DEBUG/net", "checkExists") + val response = + client.get("${endpoint!!}/bookmarks/check/") { + header("Authorization", "Token ${token!!}") + parameter("url", url) + } + val r = response.body() + r.bookmark to r.metadata + } catch (e: Exception) { + null to null + } + } + + @Serializable + data class BookmarkExistsResponse( + val bookmark: Bookmark?, + val metadata: BookmarkMetadata, + ) @Serializable data class BookmarkResponse( diff --git a/app/src/main/java/org/yrovas/linklater/domain/BookmarkAPI.kt b/app/src/main/java/org/yrovas/linklater/domain/BookmarkAPI.kt index 8b932da..b27a6f1 100644 --- a/app/src/main/java/org/yrovas/linklater/domain/BookmarkAPI.kt +++ b/app/src/main/java/org/yrovas/linklater/domain/BookmarkAPI.kt @@ -2,6 +2,7 @@ package org.yrovas.linklater.domain import kotlinx.coroutines.flow.StateFlow import org.yrovas.linklater.data.Bookmark +import org.yrovas.linklater.data.BookmarkMetadata import org.yrovas.linklater.data.LocalBookmark import org.yrovas.linklater.domain.APIError import org.yrovas.linklater.domain.Res @@ -13,4 +14,5 @@ interface BookmarkAPI { suspend fun getBookmarks(page: Int, query: String? = null): Res, APIError> suspend fun saveBookmark(bookmark: LocalBookmark): Res suspend fun getTags(page: Int): Res, APIError> + suspend fun checkExists(url: String): Pair } diff --git a/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt b/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt index d394bdf..8433568 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/screens/SaveBookmarkScreen.kt @@ -108,6 +108,7 @@ fun SaveBookmarkScreen( ) { @Suppress("NAME_SHADOWING") val state = viewModel { state() } val isSubmitting by state.isSubmitting.collectAsState() + val bookmarkExists by state.bookmarkExists.collectAsState() val scope = rememberCoroutineScope() var showTagRow by remember { mutableStateOf(false) } @@ -127,7 +128,7 @@ fun SaveBookmarkScreen( Frame( appBar = { - AppBar(page = "Add Bookmark", back = back) { + AppBar(page = if (bookmarkExists) "Edit Bookmark" else "Add Bookmark", back = back) { IconButton(onClick = { state.sendEvent(Event.SubmitBookmark) }) { Icon( imageVector = Icons.Default.Bookmark, @@ -164,6 +165,8 @@ private fun SaveBookmarkFields( context: Context = LocalContext.current, ) { val bookmark by state.bookmarkToSave.collectAsState() + val previewTitle by state.previewTitle.collectAsState() + val previewDescription by state.previewDescription.collectAsState() val showPaste by state.showPaste.collectAsState() Column( @@ -184,34 +187,37 @@ private fun SaveBookmarkFields( StyledTagRow(state, onTagFocus) - Spacer(modifier = Modifier.height(padding.standard)) - - StyledCheckBox("Share", bookmark.shared, onCheckedChange = { - state.sendEvent(Event.UpdateBookmark(shared = it)) - }) - StyledCheckBox("Mark as unread", bookmark.unread, onCheckedChange = { - state.sendEvent(Event.UpdateBookmark(unread = it)) - }) - - Spacer(modifier = Modifier.height(padding.standard)) - StyledTextField( name = "Title", value = bookmark.title ?: "", icon = Icons.Default.Title, - placeholder = "Leave blank to use website title" + placeholder = previewTitle ?: "Leave blank to use website title" ) { state.sendEvent(Event.UpdateBookmark(title = it.ifBlank { null })) } + StyledTextField( name = "Description", value = bookmark.description ?: "", icon = Icons.AutoMirrored.Filled.ShortText, - placeholder = "Leave blank to use website description" + placeholder = previewDescription ?: "Leave blank to use website description" ) { state.sendEvent(Event.UpdateBookmark(description = it.ifBlank { null })) } + TagSelectRow(state) + + Spacer(modifier = Modifier.height(padding.standard)) + + StyledCheckBox("Share", bookmark.shared, onCheckedChange = { + state.sendEvent(Event.UpdateBookmark(shared = it)) + }) + StyledCheckBox("Mark as unread", bookmark.unread, onCheckedChange = { + state.sendEvent(Event.UpdateBookmark(unread = it)) + }) + + Spacer(modifier = Modifier.height(padding.standard)) + StyledTextField( name = "Notes", value = bookmark.notes ?: "", @@ -275,19 +281,13 @@ fun SelectedTag(tag: String, onClick: (() -> Unit)) { } } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun StyledTagRow( state: SaveBookmarkScreenState, onFocus: (Boolean) -> Unit, ) { - var collapseTags by remember { mutableStateOf(true) } val tagNames by state.tagNames.collectAsState() - val tags by state.tags.collectAsState() val selectedTags by state.selectedTags.collectAsState() - val unselectedTags = remember(tags, selectedTags) { - mutableStateOf(tags - selectedTags.toSet()) - } StyledTextField( name = "Tags", @@ -304,6 +304,20 @@ private fun StyledTagRow( } } } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TagSelectRow( + state: SaveBookmarkScreenState, +) { + var collapseTags by remember { mutableStateOf(true) } + val tags by state.tags.collectAsState() + val selectedTags by state.selectedTags.collectAsState() + val unselectedTags = remember(tags, selectedTags) { + mutableStateOf(tags - selectedTags.toSet()) + } + val rowSize by remember(unselectedTags.value) { mutableIntStateOf( max(round(unselectedTags.value.size / 3f).toInt() + 1, 8) @@ -354,6 +368,7 @@ private fun StyledTagRow( } } } + } @Composable diff --git a/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt b/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt index e4555af..f210284 100644 --- a/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt +++ b/app/src/main/java/org/yrovas/linklater/ui/state/SaveBookmarkScreenState.kt @@ -1,8 +1,8 @@ package org.yrovas.linklater.ui.state -import android.util.Log import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -67,6 +67,16 @@ class SaveBookmarkScreenState( private val _predictionTags = MutableStateFlow(emptyList()) val predictedTags = _predictionTags.asStateFlow() + private val _previewTitle: MutableStateFlow = MutableStateFlow(null) + val previewTitle = _previewTitle.asStateFlow() + + private val _previewDescription: MutableStateFlow = + MutableStateFlow(null) + val previewDescription = _previewDescription.asStateFlow() + + private val _bookmarkExists = MutableStateFlow(false) + val bookmarkExists = _bookmarkExists.asStateFlow() + private val _showPaste = MutableStateFlow(bookmarkToSave.value.url.isBlank()) val showPaste = _showPaste.asStateFlow() @@ -82,7 +92,6 @@ class SaveBookmarkScreenState( unread: Boolean? = null, shared: Boolean? = null, ) { - _showPaste.update { url.isNullOrBlank() } _bookmarkToSave.update { bookmarkToSave.value.withUpdates( url = url, @@ -94,6 +103,7 @@ class SaveBookmarkScreenState( shared = shared, ) } + _showPaste.update { bookmarkToSave.value.url.isBlank() } } private fun updateTagNames(tagNameString: String) { @@ -158,26 +168,36 @@ class SaveBookmarkScreenState( _tags.update { tagList.sorted() } } + private lateinit var defaultBookmark: LocalBookmark private fun getDefaultBookmark() { viewModelScope.launch(Dispatchers.IO) { - updateBookmark( + defaultBookmark = LocalBookmark( + url = "", is_archived = prefStore.getPref( Prefs.BOOKMARK_DEFAULT_ARCHIVED, false ), unread = prefStore.getPref(Prefs.BOOKMARK_DEFAULT_UNREAD, false), shared = prefStore.getPref(Prefs.BOOKMARK_DEFAULT_SHARED, false) ) + setDefaultBookmark() } } + private fun setDefaultBookmark() { + updateBookmark( + is_archived = defaultBookmark.is_archived, + unread = defaultBookmark.unread, + shared = defaultBookmark.shared + ) + } + + private lateinit var defaultTags: List private fun getDefaultTags() { viewModelScope.launch(Dispatchers.IO) { - val selectedTagList = + defaultTags = prefStore.getPref(Prefs.BOOKMARK_DEFAULT_TAG_NAMES, "").intoTags() - setTagList((tags.value + selectedTagList).distinct()) - selectedTagList.forEach { - toggleSelectTag(it) - } + setTagList((tags.value + defaultTags).distinct()) + _selectedTags.update { defaultTags } } } @@ -189,17 +209,66 @@ class SaveBookmarkScreenState( } } + private fun checkBookmarkExists(url: String): Job { + return viewModelScope.launch { + val (b, m) = bookmarkAPI.checkExists(url) + m?.title?.let { title -> + _previewTitle.update { title } + } + m?.description?.let { description -> + _previewDescription.update { description } + } + + if (b != null) { + _bookmarkExists.update { true } + updateBookmark( + url = b.url, + title = b.title, + description = b.description, + notes = b.notes, + is_archived = b.is_archived, + unread = b.unread, + shared = b.shared, + ) + + setTagList((tags.value + b.tags).distinct()) // guard against tags not being present in database +// _tagNames.update { "" } + _selectedTags.update { emptyList() } + _selectedTags.update { b.tags } + } else { + _bookmarkExists.update { false } + } + } + } + init { getTagList() getDefaultBookmark() getDefaultTags() } + private fun clearPreview() { + _bookmarkExists.update { false } + _previewTitle.update { null } + _previewDescription.update { null } + _selectedTags.update { defaultTags } + setDefaultBookmark() + } + + private var checkExistsJob: Job? = null override fun handleEvent(event: Event) { when (event) { Event.SubmitBookmark -> submitBookmark() is Event.ToggleSelectTag -> toggleSelectTag(event.tagName) - is Event.UpdateBookmark -> updateBookmark( + is Event.UpdateBookmark -> { + viewModelScope.launch { + checkExistsJob?.cancel() + clearPreview() + if (!event.url.isNullOrBlank() && checkURL(event.url)) { + checkExistsJob = checkBookmarkExists(event.url) + } + } + updateBookmark( url = event.url, title = event.title, description = event.description, @@ -207,7 +276,8 @@ class SaveBookmarkScreenState( is_archived = event.is_archived, unread = event.unread, shared = event.shared, - ) + ) + } is Event.UpdateTagNames -> updateTagNames(event.tagNames) is Event.SelectTagPrediction -> selectTagPrediction(event.tagName) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7fa7a4..12cbdbd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] app-versionID = "org.yrovas.linklater" -app-versionCode = "11" -app-versionName = "0.1.10" +app-versionCode = "12" +app-versionName = "0.1.11" app-compileSDK = "34" app-targetSDK = "34" app-minimumSDK = "23"