From 9383042f094da16d7a5cfc52236cc7ba1663db0f Mon Sep 17 00:00:00 2001 From: William Brawner Date: Mon, 19 Sep 2022 19:46:38 -0600 Subject: [PATCH] Improvements to entry list screen --- .idea/inspectionProfiles/Project_Default.xml | 5 + .idea/misc.xml | 2 + app/src/main/AndroidManifest.xml | 4 +- .../data/viewmodel/UnreadViewModel.kt | 38 +++- .../java/com/wbrawner/nanoflux/ui/Entries.kt | 210 +++++++++++------- .../com/wbrawner/nanoflux/ui/EntryScreen.kt | 9 +- .../com/wbrawner/nanoflux/ui/UnreadScreen.kt | 29 ++- gradle/libs.versions.toml | 15 +- .../nanoflux/network/MinifluxApiService.kt | 43 +++- .../network/repository/CategoryRepository.kt | 4 +- .../network/repository/EntryRepository.kt | 52 ++++- .../network/repository/FeedRepository.kt | 2 + .../network/repository/IconRepository.kt | 2 + .../network/repository/UserRepository.kt | 5 +- .../wbrawner/nanoflux/storage/dao/EntryDao.kt | 4 + 15 files changed, 297 insertions(+), 127 deletions(-) diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 2842237..d235beb 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -2,18 +2,23 @@ diff --git a/.idea/misc.xml b/.idea/misc.xml index 07ec910..72e1dad 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -26,6 +26,8 @@ + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9924efa..d47581a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,8 +26,8 @@ diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt index 6ad7052..d6b1d23 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt @@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope import com.wbrawner.nanoflux.network.repository.CategoryRepository import com.wbrawner.nanoflux.network.repository.EntryRepository import com.wbrawner.nanoflux.network.repository.FeedRepository +import com.wbrawner.nanoflux.network.repository.IconRepository import com.wbrawner.nanoflux.storage.model.Entry -import com.wbrawner.nanoflux.storage.model.EntryAndFeed +import com.wbrawner.nanoflux.syncAll import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -19,7 +21,8 @@ class UnreadViewModel @Inject constructor( private val feedRepository: FeedRepository, private val categoryRepository: CategoryRepository, private val entryRepository: EntryRepository, - private val logger: Timber.Tree + private val iconRepository: IconRepository, + private val logger: Timber.Tree, ) : ViewModel() { private val _loading = MutableStateFlow(false) val loading = _loading.asStateFlow() @@ -27,22 +30,35 @@ class UnreadViewModel @Inject constructor( val errorMessage = _errorMessage.asStateFlow() val entries = entryRepository.observeUnread() - init { - logger.v("Unread entryRepo: ${entryRepository}r") - } - fun dismissError() { _errorMessage.value = null } // TODO: Get Base URL - fun getShareUrl(entry: Entry) = "baseUrl/${entry.shareCode}" + suspend fun share(entry: Entry): String? { + // Miniflux API doesn't currently support sharing so we have to send the external link for now + // https://github.com/miniflux/v2/issues/844 +// return withContext(Dispatchers.IO) { +// entryRepository.share(entry) +// } + return entry.url + } fun refresh() = viewModelScope.launch(Dispatchers.IO) { _loading.emit(true) - feedRepository.getAll(fetch = true) - categoryRepository.getAll(fetch = true) - entryRepository.getAll(fetch = true) + syncAll(categoryRepository, feedRepository, iconRepository, entryRepository) _loading.emit(false) } + + fun toggleRead(entry: Entry) = viewModelScope.launch(Dispatchers.IO) { + if (entry.status == Entry.Status.READ) { + entryRepository.markEntryUnread(entry) + } else if (entry.status == Entry.Status.UNREAD) { + entryRepository.markEntryRead(entry) + } + } + + fun toggleStar(entry: Entry) = viewModelScope.launch(Dispatchers.IO) { + entryRepository.toggleStar(entry) + } } \ No newline at end of file diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt index 0551935..d467371 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt @@ -1,8 +1,9 @@ package com.wbrawner.nanoflux.ui -import android.content.Intent import android.graphics.BitmapFactory import android.util.Base64 +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -10,14 +11,18 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.OpenInBrowser import androidx.compose.material.icons.outlined.Star import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.swiperefresh.SwipeRefresh @@ -27,6 +32,10 @@ import com.wbrawner.nanoflux.storage.model.* import java.util.* import kotlin.math.roundToInt +@OptIn( + ExperimentalMaterialApi::class, ExperimentalAnimationApi::class, + ExperimentalFoundationApi::class +) @Composable fun EntryList( entries: List, @@ -34,7 +43,8 @@ fun EntryList( onFeedClicked: (feed: Feed) -> Unit, onToggleReadClicked: (entry: Entry) -> Unit, onStarClicked: (entry: Entry) -> Unit, - onExternalLinkClicked: @Composable (entry: Entry) -> Unit, + onShareClicked: (entry: Entry) -> Unit, + onExternalLinkClicked: (entry: Entry) -> Unit, isRefreshing: Boolean, onRefresh: () -> Unit ) { @@ -43,16 +53,58 @@ fun EntryList( onRefresh = onRefresh ) { LazyColumn { - items(entries) { entry -> - EntryListItem( - entry.entry, - entry.feed, - onEntryItemClicked, - onFeedClicked, - onToggleReadClicked, - onStarClicked, - onExternalLinkClicked - ) + items(entries, { entry -> entry.entry.id }) { entry -> + val dismissState = rememberDismissState() + LaunchedEffect(key1 = dismissState.currentValue) { + when (dismissState.currentValue) { + DismissValue.DismissedToStart -> onToggleReadClicked(entry.entry) + DismissValue.DismissedToEnd -> { + onStarClicked(entry.entry) + dismissState.reset() + } + DismissValue.Default -> {} + } + } + SwipeToDismiss( + modifier = Modifier.animateItemPlacement(), + state = dismissState, + background = { + val (color, text, icon) = when (dismissState.dismissDirection) { + DismissDirection.StartToEnd -> + if (entry.entry.starred) { + Triple(Color.Yellow, "Unstarred", Icons.Outlined.Star) + } else { + Triple(Color.Yellow, "Starred", Icons.Filled.Star) + } + DismissDirection.EndToStart -> Triple( + Color.Green, + "Read", + Icons.Default.Email + ) + else -> Triple(MaterialTheme.colors.surface, "", Icons.Default.Info) + } + Surface(modifier = Modifier.fillMaxSize(), color = color) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Image(imageVector = icon, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(text) + } + } + } + ) { + EntryListItem( + entry.entry, + entry.feed, + onEntryItemClicked, + onFeedClicked = onFeedClicked, + onExternalLinkClicked = onExternalLinkClicked, + onShareClicked = onShareClicked, + ) + } } } } @@ -64,85 +116,75 @@ fun EntryListItem( feed: FeedCategoryIcon, onEntryItemClicked: (entry: Entry) -> Unit, onFeedClicked: (feed: Feed) -> Unit, - onToggleReadClicked: (entry: Entry) -> Unit, - onStarClicked: (entry: Entry) -> Unit, - onExternalLinkClicked: @Composable (entry: Entry) -> Unit + onExternalLinkClicked: (entry: Entry) -> Unit, + onShareClicked: (entry: Entry) -> Unit ) { -// val swipeState = rememberSwipeableState(initialValue = entry.status) - Column( + Surface( modifier = Modifier - .padding(vertical = 8.dp, horizontal = 16.dp) - .clickable { onEntryItemClicked(entry) } -// .swipeable(state = swipeState, orientation = Orientation.Horizontal) + .clickable { onEntryItemClicked(entry) }, + color = MaterialTheme.colors.surface ) { - Row { - Text( - modifier = Modifier.weight(1f), - text = entry.title, - style = MaterialTheme.typography.h6 - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() + Column( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), ) { - TextButton( - onClick = { onFeedClicked(feed.feed) }, - modifier = Modifier.padding(start = 0.dp), - ) { - feed.icon?.let { - val bytes = Base64.decode(it.data.substringAfter(","), Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - ?.asImageBitmap() - ?: return@let - Image( - modifier = Modifier - .width(16.dp) - .height(16.dp), - bitmap = bitmap, - contentDescription = null, - ) - Spacer(modifier = Modifier.width(8.dp)) - } + Row { Text( - text = feed.feed.title, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onSurface + modifier = Modifier.weight(1f), + text = entry.title, + style = MaterialTheme.typography.h6 ) } -// Text(text = feed.category.title) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2) - IconButton(onClick = { onStarClicked(entry) }) { - Icon( - imageVector = if (entry.starred) Icons.Filled.Star else Icons.Outlined.Star, - contentDescription = if (entry.starred) "Starred" else "Not starred" - ) - } - val context = LocalContext.current - IconButton(onClick = { - val intent = Intent(Intent.ACTION_SEND).apply { - // TODO: Get base url from viewmodel or something - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - "https://wbrawner.com/entry/share/${entry.shareCode}" + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + ) { + TextButton( + onClick = { onFeedClicked(feed.feed) }, + modifier = Modifier.padding(start = 0.dp), + ) { + feed.icon?.let { + val bytes = Base64.decode(it.data.substringAfter(","), Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + ?.asImageBitmap() + ?: return@let + Image( + modifier = Modifier + .width(16.dp) + .height(16.dp), + bitmap = bitmap, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = feed.feed.title, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface + ) + } +// Text(text = feed.category.title) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2) + IconButton(onClick = { onExternalLinkClicked(entry) }) { + Icon( + imageVector = Icons.Outlined.OpenInBrowser, + contentDescription = "Open in browser" + ) + } + IconButton(onClick = { onShareClicked(entry) }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share" ) } - context.startActivity(Intent.createChooser(intent, null)) - }) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = "Share" - ) } } } @@ -208,7 +250,7 @@ fun EntryListItem_Preview() { mimeType = "image/png" ) ), - {}, {}, {}, {}, {} + {}, {}, {}, {} ) } } diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt index 32d1141..c979313 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt @@ -52,13 +52,14 @@ fun Entry(entry: EntryAndFeed) { feed = entry.feed, onEntryItemClicked = { /*TODO*/ }, onFeedClicked = { /*TODO*/ }, - onToggleReadClicked = { /*TODO*/ }, - onStarClicked = { /*TODO*/ }, - onExternalLinkClicked = { /* TODO */ } + onExternalLinkClicked = { /*TODO*/ }, + onShareClicked = { /*TODO*/ }, ) // Adds view to Compose AndroidView( - modifier = Modifier.fillMaxSize().padding(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp), factory = { context -> TextView(context).apply { text = Html.fromHtml(entry.entry.content) diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt index fadf45e..8cf5fa8 100644 --- a/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt +++ b/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt @@ -2,8 +2,12 @@ package com.wbrawner.nanoflux.ui import android.content.Intent import android.net.Uri -import androidx.compose.material.* -import androidx.compose.runtime.* +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController @@ -28,6 +32,7 @@ fun UnreadScreen( } } } + val context = LocalContext.current EntryList( entries = entries, onEntryItemClicked = { @@ -36,10 +41,24 @@ fun UnreadScreen( onFeedClicked = { navController.navigate("feeds/${it.id}") }, - onToggleReadClicked = { /*TODO*/ }, - onStarClicked = { /*TODO*/ }, + onToggleReadClicked = { unreadViewModel.toggleRead(it) }, + onStarClicked = { unreadViewModel.toggleStar(it) }, onExternalLinkClicked = { - LocalContext.current.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url))) + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url))) + }, + onShareClicked = { entry -> + coroutineScope.launch { + unreadViewModel.share(entry)?.let { url -> + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + url + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } }, isRefreshing = loading, onRefresh = { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef07eed..19dfbc0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,13 +18,14 @@ versionName = "1.0" work = "2.7.1" [libraries] -accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version = "0.26.3-beta"} +accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version = "0.26.3-beta" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } material = { module = "com.google.android.material:material", version.ref = "material" } compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-ui" } compose-material = { module = "androidx.compose.material:material", version.ref = "compose-ui" } +compose-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose-ui" } compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose-ui" } compose-activity = { module = "androidx.activity:activity-compose", version = "1.5.1" } compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose-ui" } @@ -42,13 +43,13 @@ junit = { module = "junit:junit", version = "4.12" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-reflection = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } -kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.4.0"} -ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor"} -ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor"} +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.4.0" } +ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ktor-logging = { module = "io.ktor:ktor-client-logging-jvm", version.ref = "ktor"} -ktor-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor"} +ktor-logging = { module = "io.ktor:ktor-client-logging-jvm", version.ref = "ktor" } +ktor-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } room-kapt = { module = "androidx.room:room-compiler", version.ref = "room" } room-test = { module = "androidx.room:room-testing", version.ref = "room" } @@ -59,7 +60,7 @@ work-core = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } work-test = { module = "androidx.work:work-testing", version.ref = "work" } [bundles] -compose = ["compose-compiler", "compose-ui", "compose-material", "compose-tooling", "compose-activity", "navigation-compose"] +compose = ["compose-compiler", "compose-ui", "compose-material", "compose-icons", "compose-tooling", "compose-activity", "navigation-compose"] coroutines = ["coroutines-core", "coroutines-android"] networking = ["kotlin-reflection", "kotlinx-serialization", "ktor-core", "ktor-cio", "ktor-content-negotiation", "ktor-json", "ktor-logging", "ktor-serialization"] diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt index 297e209..ac909c5 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt @@ -1,6 +1,7 @@ package com.wbrawner.nanoflux.network import android.content.SharedPreferences +import androidx.core.content.edit import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN import com.wbrawner.nanoflux.storage.model.* import io.ktor.client.* @@ -22,7 +23,7 @@ import javax.inject.Inject interface MinifluxApiService { - fun setBaseUrl(url: String) + var baseUrl: String suspend fun discoverSubscriptions( url: String, @@ -91,6 +92,8 @@ interface MinifluxApiService { suspend fun toggleEntryBookmark(id: Long): HttpResponse + suspend fun shareEntry(entry: Entry): HttpResponse + suspend fun getCategories(): List suspend fun createCategory(title: String): Category @@ -169,7 +172,10 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long) data class EntryResponse(val total: Long, val entries: List) @Serializable -data class UpdateEntryRequest(@SerialName("entry_ids") val entryIds: List, val status: Entry.Status) +data class UpdateEntryRequest( + @SerialName("entry_ids") val entryIds: List, + val status: Entry.Status +) @Suppress("unused") class KtorMinifluxApiService @Inject constructor( @@ -194,13 +200,19 @@ class KtorMinifluxApiService @Inject constructor( } } private val _baseUrl: AtomicReference = AtomicReference() - private val baseUrl: String + override var baseUrl: String get() = _baseUrl.get() ?: run { sharedPreferences.getString(PREF_KEY_BASE_URL, null)?.let { _baseUrl.set(it) it } ?: "" } + set(value) { + _baseUrl.set(value) + sharedPreferences.edit { + putString(PREF_KEY_BASE_URL, value) + } + } private fun url( path: String, @@ -220,10 +232,6 @@ class KtorMinifluxApiService @Inject constructor( return url.build() } - override fun setBaseUrl(url: String) { - _baseUrl.set(url) - } - override suspend fun discoverSubscriptions( url: String, username: String?, @@ -345,8 +353,8 @@ class KtorMinifluxApiService @Inject constructor( "status" to status?.joinToString(",") { it.name.toLowerCase() }, "offset" to offset, "limit" to limit, - "order" to order, - "direction" to direction, + "order" to order?.name?.lowercase(), + "direction" to direction?.name?.lowercase(), "before" to before, "after" to after, "before_entry_id" to beforeEntryId, @@ -375,6 +383,23 @@ class KtorMinifluxApiService @Inject constructor( override suspend fun toggleEntryBookmark(id: Long): HttpResponse = client.put(url("entries/$id/bookmark")) + override suspend fun shareEntry(entry: Entry): HttpResponse { + // This is the only method that doesn't really go through the API because there doesn't + // appear to be a documented way of getting the share code, so we have to build the URL + // manually + val url = URLBuilder(baseUrl).apply { + path("entry", "share", entry.id.toString()) + }.build() + return client.get(url) { + headers { + header( + "Authorization", + sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString() + ) + } + } + } + override suspend fun getCategories(): List = client.get(url("categories")) { headers { header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString()) diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/CategoryRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/CategoryRepository.kt index accfbb1..cc07e23 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/CategoryRepository.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/CategoryRepository.kt @@ -5,7 +5,9 @@ import com.wbrawner.nanoflux.storage.dao.CategoryDao import com.wbrawner.nanoflux.storage.model.Category import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton +@Singleton class CategoryRepository @Inject constructor( private val apiService: MinifluxApiService, private val categoryDao: CategoryDao, @@ -20,6 +22,6 @@ class CategoryRepository @Inject constructor( } suspend fun getById(id: Long): Category? = categoryDao.getAllByIds(id).firstOrNull() - ?: getAll(true) + ?: getAll(true) .firstOrNull { it.id == id } } \ No newline at end of file diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt index b750f75..5e0b7bb 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt @@ -1,14 +1,20 @@ package com.wbrawner.nanoflux.network.repository +import android.net.Uri import com.wbrawner.nanoflux.network.EntryResponse import com.wbrawner.nanoflux.network.MinifluxApiService import com.wbrawner.nanoflux.storage.dao.EntryDao import com.wbrawner.nanoflux.storage.model.Entry import com.wbrawner.nanoflux.storage.model.EntryAndFeed +import io.ktor.http.* +import io.ktor.util.network.* import kotlinx.coroutines.flow.Flow import timber.log.Timber +import java.io.IOException import javax.inject.Inject +import javax.inject.Singleton +@Singleton class EntryRepository @Inject constructor( private val apiService: MinifluxApiService, private val entryDao: EntryDao, @@ -22,6 +28,8 @@ class EntryRepository @Inject constructor( if (fetch) { getEntries { page -> apiService.getEntries( + order = Entry.Order.PUBLISHED_AT, + direction = Entry.SortDirection.DESC, offset = 100L * page, limit = 100, afterEntryId = afterId @@ -54,13 +62,37 @@ class EntryRepository @Inject constructor( } suspend fun markEntryRead(entry: Entry) { - apiService.updateEntries(listOf(entry.id), Entry.Status.READ) entryDao.update(entry.copy(status = Entry.Status.READ)) + retryOnFailure { + apiService.updateEntries(listOf(entry.id), Entry.Status.READ) + } } suspend fun markEntryUnread(entry: Entry) { - apiService.updateEntries(listOf(entry.id), Entry.Status.UNREAD) entryDao.update(entry.copy(status = Entry.Status.UNREAD)) + retryOnFailure { + apiService.updateEntries(listOf(entry.id), Entry.Status.UNREAD) + } + } + + suspend fun toggleStar(entry: Entry) { + entryDao.update(entry.copy(starred = !entry.starred)) + retryOnFailure { + apiService.toggleEntryBookmark(entry.id) + } + } + + suspend fun share(entry: Entry): String? { + if (entry.shareCode.isNotBlank()) { + return Uri.parse(apiService.baseUrl) + .buildUpon() + .path("/entry/share/${entry.shareCode}") + .build() + .toString() + } + + val response = apiService.shareEntry(entry) + return response.headers[HttpHeaders.Location] } fun getLatestId(): Long = entryDao.getLatestId() @@ -76,6 +108,22 @@ class EntryRepository @Inject constructor( totalPages = response.total / 100 } } + + + private suspend fun retryOnFailure(request: suspend () -> Any) { + try { + request() + } catch (e: Exception) { + when (e) { + is IOException, is UnresolvedAddressException -> { + // TODO: Save request to retry later + logger.e("Network request failed", e) + } + // TODO: Log to Crashlytics or something instead of just crashing + else -> throw RuntimeException("Unhandled exception marking entry as read", e) + } + } + } } enum class EntryStatus { diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/FeedRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/FeedRepository.kt index e61f626..3314fa8 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/FeedRepository.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/FeedRepository.kt @@ -5,7 +5,9 @@ import com.wbrawner.nanoflux.storage.dao.FeedDao import com.wbrawner.nanoflux.storage.model.FeedCategoryIcon import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton +@Singleton class FeedRepository @Inject constructor( private val apiService: MinifluxApiService, private val feedDao: FeedDao, diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/IconRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/IconRepository.kt index ce62cb1..d50f0ab 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/IconRepository.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/IconRepository.kt @@ -5,7 +5,9 @@ import com.wbrawner.nanoflux.storage.dao.IconDao import com.wbrawner.nanoflux.storage.model.Feed import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton +@Singleton class IconRepository @Inject constructor( private val apiService: MinifluxApiService, private val iconDao: IconDao, diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt index 1d6fb7a..5254f19 100644 --- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt +++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/UserRepository.kt @@ -12,10 +12,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton const val PREF_KEY_AUTH_TOKEN = "authToken" const val PREF_KEY_CURRENT_USER = "currentUser" +@Singleton class UserRepository @Inject constructor( private val sharedPreferences: SharedPreferences, private val apiService: MinifluxApiService, @@ -52,9 +54,8 @@ class UserRepository @Inject constructor( Base64.DEFAULT or Base64.NO_WRAP ) .decodeToString() - apiService.setBaseUrl(correctedServer) + apiService.baseUrl = correctedServer sharedPreferences.edit { - putString(PREF_KEY_BASE_URL, correctedServer) putString(PREF_KEY_AUTH_TOKEN, "Basic $credentials") } try { diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt index fd8c19e..9e71046 100644 --- a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt +++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt @@ -18,6 +18,10 @@ interface EntryDao { @Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY publishedAt DESC") fun observeUnread(): Flow> + @Transaction + @Query("SELECT * FROM Entry WHERE status = \"READ\" ORDER BY publishedAt DESC") + fun observeRead(): Flow> + @Transaction @Query("SELECT * FROM Entry ORDER BY publishedAt DESC") fun getAll(): List