Improvements to entry list screen

This commit is contained in:
William Brawner 2022-09-19 19:46:38 -06:00
parent ed0a7d813f
commit 9383042f09
15 changed files with 297 additions and 127 deletions

View file

@ -2,18 +2,23 @@
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>

View file

@ -26,6 +26,8 @@
<entry key="../../../../layout/compose-model-1663094326625.xml" value="1.0" />
<entry key="../../../../layout/compose-model-1663101323996.xml" value="0.1" />
<entry key="../../../../layout/compose-model-1663188478804.xml" value="0.47846283783783783" />
<entry key="../../../../layout/compose-model-1663190713161.xml" value="0.562037037037037" />
<entry key="../../../../layout/compose-model-1663258519405.xml" value="0.4046296296296296" />
</map>
</option>
</component>

View file

@ -26,8 +26,8 @@
</intent-filter>
</activity>
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
</application>

View file

@ -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)
}
}

View file

@ -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<EntryAndFeed>,
@ -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"
)
),
{}, {}, {}, {}, {}
{}, {}, {}, {}
)
}
}

View file

@ -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)

View file

@ -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 = {

View file

@ -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"]

View file

@ -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<Category>
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<Entry>)
@Serializable
data class UpdateEntryRequest(@SerialName("entry_ids") val entryIds: List<Long>, val status: Entry.Status)
data class UpdateEntryRequest(
@SerialName("entry_ids") val entryIds: List<Long>,
val status: Entry.Status
)
@Suppress("unused")
class KtorMinifluxApiService @Inject constructor(
@ -194,13 +200,19 @@ class KtorMinifluxApiService @Inject constructor(
}
}
private val _baseUrl: AtomicReference<String?> = 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<Category> = client.get(url("categories")) {
headers {
header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())

View file

@ -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 }
}

View file

@ -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 {

View file

@ -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,

View file

@ -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,

View file

@ -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 {

View file

@ -18,6 +18,10 @@ interface EntryDao {
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY publishedAt DESC")
fun observeUnread(): Flow<List<EntryAndFeed>>
@Transaction
@Query("SELECT * FROM Entry WHERE status = \"READ\" ORDER BY publishedAt DESC")
fun observeRead(): Flow<List<EntryAndFeed>>
@Transaction
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
fun getAll(): List<EntryAndFeed>