Improvements to entry list screen
This commit is contained in:
parent
ed0a7d813f
commit
9383042f09
15 changed files with 297 additions and 127 deletions
|
@ -2,18 +2,23 @@
|
||||||
<profile version="1.0">
|
<profile version="1.0">
|
||||||
<option name="myName" value="Project Default" />
|
<option name="myName" value="Project Default" />
|
||||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
|
<option name="composableFile" value="true" />
|
||||||
<option name="previewFile" value="true" />
|
<option name="previewFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
</profile>
|
</profile>
|
||||||
|
|
|
@ -26,6 +26,8 @@
|
||||||
<entry key="../../../../layout/compose-model-1663094326625.xml" value="1.0" />
|
<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-1663101323996.xml" value="0.1" />
|
||||||
<entry key="../../../../layout/compose-model-1663188478804.xml" value="0.47846283783783783" />
|
<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>
|
</map>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -26,8 +26,8 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.work.impl.WorkManagerInitializer"
|
android:name="androidx.startup.InitializationProvider"
|
||||||
android:authorities="${applicationId}.workmanager-init"
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
|
|
|
@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.wbrawner.nanoflux.network.repository.CategoryRepository
|
import com.wbrawner.nanoflux.network.repository.CategoryRepository
|
||||||
import com.wbrawner.nanoflux.network.repository.EntryRepository
|
import com.wbrawner.nanoflux.network.repository.EntryRepository
|
||||||
import com.wbrawner.nanoflux.network.repository.FeedRepository
|
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.Entry
|
||||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
import com.wbrawner.nanoflux.syncAll
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -19,7 +21,8 @@ class UnreadViewModel @Inject constructor(
|
||||||
private val feedRepository: FeedRepository,
|
private val feedRepository: FeedRepository,
|
||||||
private val categoryRepository: CategoryRepository,
|
private val categoryRepository: CategoryRepository,
|
||||||
private val entryRepository: EntryRepository,
|
private val entryRepository: EntryRepository,
|
||||||
private val logger: Timber.Tree
|
private val iconRepository: IconRepository,
|
||||||
|
private val logger: Timber.Tree,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _loading = MutableStateFlow(false)
|
private val _loading = MutableStateFlow(false)
|
||||||
val loading = _loading.asStateFlow()
|
val loading = _loading.asStateFlow()
|
||||||
|
@ -27,22 +30,35 @@ class UnreadViewModel @Inject constructor(
|
||||||
val errorMessage = _errorMessage.asStateFlow()
|
val errorMessage = _errorMessage.asStateFlow()
|
||||||
val entries = entryRepository.observeUnread()
|
val entries = entryRepository.observeUnread()
|
||||||
|
|
||||||
init {
|
|
||||||
logger.v("Unread entryRepo: ${entryRepository}r")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissError() {
|
fun dismissError() {
|
||||||
_errorMessage.value = null
|
_errorMessage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Get Base URL
|
// 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) {
|
fun refresh() = viewModelScope.launch(Dispatchers.IO) {
|
||||||
_loading.emit(true)
|
_loading.emit(true)
|
||||||
feedRepository.getAll(fetch = true)
|
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
|
||||||
categoryRepository.getAll(fetch = true)
|
|
||||||
entryRepository.getAll(fetch = true)
|
|
||||||
_loading.emit(false)
|
_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)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
package com.wbrawner.nanoflux.ui
|
package com.wbrawner.nanoflux.ui
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
@ -10,14 +11,18 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
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.Share
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material.icons.outlined.OpenInBrowser
|
||||||
import androidx.compose.material.icons.outlined.Star
|
import androidx.compose.material.icons.outlined.Star
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||||
|
@ -27,6 +32,10 @@ import com.wbrawner.nanoflux.storage.model.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@OptIn(
|
||||||
|
ExperimentalMaterialApi::class, ExperimentalAnimationApi::class,
|
||||||
|
ExperimentalFoundationApi::class
|
||||||
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun EntryList(
|
fun EntryList(
|
||||||
entries: List<EntryAndFeed>,
|
entries: List<EntryAndFeed>,
|
||||||
|
@ -34,7 +43,8 @@ fun EntryList(
|
||||||
onFeedClicked: (feed: Feed) -> Unit,
|
onFeedClicked: (feed: Feed) -> Unit,
|
||||||
onToggleReadClicked: (entry: Entry) -> Unit,
|
onToggleReadClicked: (entry: Entry) -> Unit,
|
||||||
onStarClicked: (entry: Entry) -> Unit,
|
onStarClicked: (entry: Entry) -> Unit,
|
||||||
onExternalLinkClicked: @Composable (entry: Entry) -> Unit,
|
onShareClicked: (entry: Entry) -> Unit,
|
||||||
|
onExternalLinkClicked: (entry: Entry) -> Unit,
|
||||||
isRefreshing: Boolean,
|
isRefreshing: Boolean,
|
||||||
onRefresh: () -> Unit
|
onRefresh: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
@ -43,19 +53,61 @@ fun EntryList(
|
||||||
onRefresh = onRefresh
|
onRefresh = onRefresh
|
||||||
) {
|
) {
|
||||||
LazyColumn {
|
LazyColumn {
|
||||||
items(entries) { entry ->
|
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(
|
EntryListItem(
|
||||||
entry.entry,
|
entry.entry,
|
||||||
entry.feed,
|
entry.feed,
|
||||||
onEntryItemClicked,
|
onEntryItemClicked,
|
||||||
onFeedClicked,
|
onFeedClicked = onFeedClicked,
|
||||||
onToggleReadClicked,
|
onExternalLinkClicked = onExternalLinkClicked,
|
||||||
onStarClicked,
|
onShareClicked = onShareClicked,
|
||||||
onExternalLinkClicked
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -64,16 +116,16 @@ fun EntryListItem(
|
||||||
feed: FeedCategoryIcon,
|
feed: FeedCategoryIcon,
|
||||||
onEntryItemClicked: (entry: Entry) -> Unit,
|
onEntryItemClicked: (entry: Entry) -> Unit,
|
||||||
onFeedClicked: (feed: Feed) -> Unit,
|
onFeedClicked: (feed: Feed) -> Unit,
|
||||||
onToggleReadClicked: (entry: Entry) -> Unit,
|
onExternalLinkClicked: (entry: Entry) -> Unit,
|
||||||
onStarClicked: (entry: Entry) -> Unit,
|
onShareClicked: (entry: Entry) -> Unit
|
||||||
onExternalLinkClicked: @Composable (entry: Entry) -> Unit
|
|
||||||
) {
|
) {
|
||||||
// val swipeState = rememberSwipeableState(initialValue = entry.status)
|
Surface(
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
.clickable { onEntryItemClicked(entry) },
|
||||||
.clickable { onEntryItemClicked(entry) }
|
color = MaterialTheme.colors.surface
|
||||||
// .swipeable(state = swipeState, orientation = Orientation.Horizontal)
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||||
) {
|
) {
|
||||||
Row {
|
Row {
|
||||||
Text(
|
Text(
|
||||||
|
@ -121,24 +173,13 @@ fun EntryListItem(
|
||||||
Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2)
|
Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2)
|
Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2)
|
||||||
IconButton(onClick = { onStarClicked(entry) }) {
|
IconButton(onClick = { onExternalLinkClicked(entry) }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (entry.starred) Icons.Filled.Star else Icons.Outlined.Star,
|
imageVector = Icons.Outlined.OpenInBrowser,
|
||||||
contentDescription = if (entry.starred) "Starred" else "Not starred"
|
contentDescription = "Open in browser"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val context = LocalContext.current
|
IconButton(onClick = { onShareClicked(entry) }) {
|
||||||
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}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
context.startActivity(Intent.createChooser(intent, null))
|
|
||||||
}) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Share,
|
imageVector = Icons.Default.Share,
|
||||||
contentDescription = "Share"
|
contentDescription = "Share"
|
||||||
|
@ -146,6 +187,7 @@ fun EntryListItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -208,7 +250,7 @@ fun EntryListItem_Preview() {
|
||||||
mimeType = "image/png"
|
mimeType = "image/png"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
{}, {}, {}, {}, {}
|
{}, {}, {}, {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,13 +52,14 @@ fun Entry(entry: EntryAndFeed) {
|
||||||
feed = entry.feed,
|
feed = entry.feed,
|
||||||
onEntryItemClicked = { /*TODO*/ },
|
onEntryItemClicked = { /*TODO*/ },
|
||||||
onFeedClicked = { /*TODO*/ },
|
onFeedClicked = { /*TODO*/ },
|
||||||
onToggleReadClicked = { /*TODO*/ },
|
onExternalLinkClicked = { /*TODO*/ },
|
||||||
onStarClicked = { /*TODO*/ },
|
onShareClicked = { /*TODO*/ },
|
||||||
onExternalLinkClicked = { /* TODO */ }
|
|
||||||
)
|
)
|
||||||
// Adds view to Compose
|
// Adds view to Compose
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
TextView(context).apply {
|
TextView(context).apply {
|
||||||
text = Html.fromHtml(entry.entry.content)
|
text = Html.fromHtml(entry.entry.content)
|
||||||
|
|
|
@ -2,8 +2,12 @@ package com.wbrawner.nanoflux.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.SnackbarHostState
|
||||||
import androidx.compose.runtime.*
|
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.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
@ -28,6 +32,7 @@ fun UnreadScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
EntryList(
|
EntryList(
|
||||||
entries = entries,
|
entries = entries,
|
||||||
onEntryItemClicked = {
|
onEntryItemClicked = {
|
||||||
|
@ -36,10 +41,24 @@ fun UnreadScreen(
|
||||||
onFeedClicked = {
|
onFeedClicked = {
|
||||||
navController.navigate("feeds/${it.id}")
|
navController.navigate("feeds/${it.id}")
|
||||||
},
|
},
|
||||||
onToggleReadClicked = { /*TODO*/ },
|
onToggleReadClicked = { unreadViewModel.toggleRead(it) },
|
||||||
onStarClicked = { /*TODO*/ },
|
onStarClicked = { unreadViewModel.toggleStar(it) },
|
||||||
onExternalLinkClicked = {
|
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,
|
isRefreshing = loading,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
|
|
|
@ -18,13 +18,14 @@ versionName = "1.0"
|
||||||
work = "2.7.1"
|
work = "2.7.1"
|
||||||
|
|
||||||
[libraries]
|
[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-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||||
compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" }
|
compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" }
|
||||||
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-ui" }
|
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-ui" }
|
||||||
compose-material = { module = "androidx.compose.material:material", 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-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose-ui" }
|
||||||
compose-activity = { module = "androidx.activity:activity-compose", version = "1.5.1" }
|
compose-activity = { module = "androidx.activity:activity-compose", version = "1.5.1" }
|
||||||
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose-ui" }
|
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-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", 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" }
|
kotlin-reflection = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||||
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.4.0"}
|
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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
ktor-cio = { module = "io.ktor:ktor-client-cio", 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-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-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
ktor-logging = { module = "io.ktor:ktor-client-logging-jvm", 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-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" }
|
||||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
room-kapt = { module = "androidx.room:room-compiler", version.ref = "room" }
|
room-kapt = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||||
room-test = { module = "androidx.room:room-testing", 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" }
|
work-test = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||||
|
|
||||||
[bundles]
|
[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"]
|
coroutines = ["coroutines-core", "coroutines-android"]
|
||||||
networking = ["kotlin-reflection", "kotlinx-serialization", "ktor-core", "ktor-cio", "ktor-content-negotiation", "ktor-json", "ktor-logging", "ktor-serialization"]
|
networking = ["kotlin-reflection", "kotlinx-serialization", "ktor-core", "ktor-cio", "ktor-content-negotiation", "ktor-json", "ktor-logging", "ktor-serialization"]
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package com.wbrawner.nanoflux.network
|
package com.wbrawner.nanoflux.network
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import androidx.core.content.edit
|
||||||
import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN
|
import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN
|
||||||
import com.wbrawner.nanoflux.storage.model.*
|
import com.wbrawner.nanoflux.storage.model.*
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
@ -22,7 +23,7 @@ import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
interface MinifluxApiService {
|
interface MinifluxApiService {
|
||||||
fun setBaseUrl(url: String)
|
var baseUrl: String
|
||||||
|
|
||||||
suspend fun discoverSubscriptions(
|
suspend fun discoverSubscriptions(
|
||||||
url: String,
|
url: String,
|
||||||
|
@ -91,6 +92,8 @@ interface MinifluxApiService {
|
||||||
|
|
||||||
suspend fun toggleEntryBookmark(id: Long): HttpResponse
|
suspend fun toggleEntryBookmark(id: Long): HttpResponse
|
||||||
|
|
||||||
|
suspend fun shareEntry(entry: Entry): HttpResponse
|
||||||
|
|
||||||
suspend fun getCategories(): List<Category>
|
suspend fun getCategories(): List<Category>
|
||||||
|
|
||||||
suspend fun createCategory(title: String): 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>)
|
data class EntryResponse(val total: Long, val entries: List<Entry>)
|
||||||
|
|
||||||
@Serializable
|
@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")
|
@Suppress("unused")
|
||||||
class KtorMinifluxApiService @Inject constructor(
|
class KtorMinifluxApiService @Inject constructor(
|
||||||
|
@ -194,13 +200,19 @@ class KtorMinifluxApiService @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val _baseUrl: AtomicReference<String?> = AtomicReference()
|
private val _baseUrl: AtomicReference<String?> = AtomicReference()
|
||||||
private val baseUrl: String
|
override var baseUrl: String
|
||||||
get() = _baseUrl.get() ?: run {
|
get() = _baseUrl.get() ?: run {
|
||||||
sharedPreferences.getString(PREF_KEY_BASE_URL, null)?.let {
|
sharedPreferences.getString(PREF_KEY_BASE_URL, null)?.let {
|
||||||
_baseUrl.set(it)
|
_baseUrl.set(it)
|
||||||
it
|
it
|
||||||
} ?: ""
|
} ?: ""
|
||||||
}
|
}
|
||||||
|
set(value) {
|
||||||
|
_baseUrl.set(value)
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putString(PREF_KEY_BASE_URL, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun url(
|
private fun url(
|
||||||
path: String,
|
path: String,
|
||||||
|
@ -220,10 +232,6 @@ class KtorMinifluxApiService @Inject constructor(
|
||||||
return url.build()
|
return url.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setBaseUrl(url: String) {
|
|
||||||
_baseUrl.set(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun discoverSubscriptions(
|
override suspend fun discoverSubscriptions(
|
||||||
url: String,
|
url: String,
|
||||||
username: String?,
|
username: String?,
|
||||||
|
@ -345,8 +353,8 @@ class KtorMinifluxApiService @Inject constructor(
|
||||||
"status" to status?.joinToString(",") { it.name.toLowerCase() },
|
"status" to status?.joinToString(",") { it.name.toLowerCase() },
|
||||||
"offset" to offset,
|
"offset" to offset,
|
||||||
"limit" to limit,
|
"limit" to limit,
|
||||||
"order" to order,
|
"order" to order?.name?.lowercase(),
|
||||||
"direction" to direction,
|
"direction" to direction?.name?.lowercase(),
|
||||||
"before" to before,
|
"before" to before,
|
||||||
"after" to after,
|
"after" to after,
|
||||||
"before_entry_id" to beforeEntryId,
|
"before_entry_id" to beforeEntryId,
|
||||||
|
@ -375,6 +383,23 @@ class KtorMinifluxApiService @Inject constructor(
|
||||||
override suspend fun toggleEntryBookmark(id: Long): HttpResponse =
|
override suspend fun toggleEntryBookmark(id: Long): HttpResponse =
|
||||||
client.put(url("entries/$id/bookmark"))
|
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")) {
|
override suspend fun getCategories(): List<Category> = client.get(url("categories")) {
|
||||||
headers {
|
headers {
|
||||||
header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
|
header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
|
||||||
|
|
|
@ -5,7 +5,9 @@ import com.wbrawner.nanoflux.storage.dao.CategoryDao
|
||||||
import com.wbrawner.nanoflux.storage.model.Category
|
import com.wbrawner.nanoflux.storage.model.Category
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
class CategoryRepository @Inject constructor(
|
class CategoryRepository @Inject constructor(
|
||||||
private val apiService: MinifluxApiService,
|
private val apiService: MinifluxApiService,
|
||||||
private val categoryDao: CategoryDao,
|
private val categoryDao: CategoryDao,
|
||||||
|
|
|
@ -1,14 +1,20 @@
|
||||||
package com.wbrawner.nanoflux.network.repository
|
package com.wbrawner.nanoflux.network.repository
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import com.wbrawner.nanoflux.network.EntryResponse
|
import com.wbrawner.nanoflux.network.EntryResponse
|
||||||
import com.wbrawner.nanoflux.network.MinifluxApiService
|
import com.wbrawner.nanoflux.network.MinifluxApiService
|
||||||
import com.wbrawner.nanoflux.storage.dao.EntryDao
|
import com.wbrawner.nanoflux.storage.dao.EntryDao
|
||||||
import com.wbrawner.nanoflux.storage.model.Entry
|
import com.wbrawner.nanoflux.storage.model.Entry
|
||||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.util.network.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.io.IOException
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
class EntryRepository @Inject constructor(
|
class EntryRepository @Inject constructor(
|
||||||
private val apiService: MinifluxApiService,
|
private val apiService: MinifluxApiService,
|
||||||
private val entryDao: EntryDao,
|
private val entryDao: EntryDao,
|
||||||
|
@ -22,6 +28,8 @@ class EntryRepository @Inject constructor(
|
||||||
if (fetch) {
|
if (fetch) {
|
||||||
getEntries { page ->
|
getEntries { page ->
|
||||||
apiService.getEntries(
|
apiService.getEntries(
|
||||||
|
order = Entry.Order.PUBLISHED_AT,
|
||||||
|
direction = Entry.SortDirection.DESC,
|
||||||
offset = 100L * page,
|
offset = 100L * page,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
afterEntryId = afterId
|
afterEntryId = afterId
|
||||||
|
@ -54,13 +62,37 @@ class EntryRepository @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun markEntryRead(entry: Entry) {
|
suspend fun markEntryRead(entry: Entry) {
|
||||||
apiService.updateEntries(listOf(entry.id), Entry.Status.READ)
|
|
||||||
entryDao.update(entry.copy(status = 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) {
|
suspend fun markEntryUnread(entry: Entry) {
|
||||||
apiService.updateEntries(listOf(entry.id), Entry.Status.UNREAD)
|
|
||||||
entryDao.update(entry.copy(status = 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()
|
fun getLatestId(): Long = entryDao.getLatestId()
|
||||||
|
@ -76,6 +108,22 @@ class EntryRepository @Inject constructor(
|
||||||
totalPages = response.total / 100
|
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 {
|
enum class EntryStatus {
|
||||||
|
|
|
@ -5,7 +5,9 @@ import com.wbrawner.nanoflux.storage.dao.FeedDao
|
||||||
import com.wbrawner.nanoflux.storage.model.FeedCategoryIcon
|
import com.wbrawner.nanoflux.storage.model.FeedCategoryIcon
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
class FeedRepository @Inject constructor(
|
class FeedRepository @Inject constructor(
|
||||||
private val apiService: MinifluxApiService,
|
private val apiService: MinifluxApiService,
|
||||||
private val feedDao: FeedDao,
|
private val feedDao: FeedDao,
|
||||||
|
|
|
@ -5,7 +5,9 @@ import com.wbrawner.nanoflux.storage.dao.IconDao
|
||||||
import com.wbrawner.nanoflux.storage.model.Feed
|
import com.wbrawner.nanoflux.storage.model.Feed
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
class IconRepository @Inject constructor(
|
class IconRepository @Inject constructor(
|
||||||
private val apiService: MinifluxApiService,
|
private val apiService: MinifluxApiService,
|
||||||
private val iconDao: IconDao,
|
private val iconDao: IconDao,
|
||||||
|
|
|
@ -12,10 +12,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
const val PREF_KEY_AUTH_TOKEN = "authToken"
|
const val PREF_KEY_AUTH_TOKEN = "authToken"
|
||||||
const val PREF_KEY_CURRENT_USER = "currentUser"
|
const val PREF_KEY_CURRENT_USER = "currentUser"
|
||||||
|
|
||||||
|
@Singleton
|
||||||
class UserRepository @Inject constructor(
|
class UserRepository @Inject constructor(
|
||||||
private val sharedPreferences: SharedPreferences,
|
private val sharedPreferences: SharedPreferences,
|
||||||
private val apiService: MinifluxApiService,
|
private val apiService: MinifluxApiService,
|
||||||
|
@ -52,9 +54,8 @@ class UserRepository @Inject constructor(
|
||||||
Base64.DEFAULT or Base64.NO_WRAP
|
Base64.DEFAULT or Base64.NO_WRAP
|
||||||
)
|
)
|
||||||
.decodeToString()
|
.decodeToString()
|
||||||
apiService.setBaseUrl(correctedServer)
|
apiService.baseUrl = correctedServer
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString(PREF_KEY_BASE_URL, correctedServer)
|
|
||||||
putString(PREF_KEY_AUTH_TOKEN, "Basic $credentials")
|
putString(PREF_KEY_AUTH_TOKEN, "Basic $credentials")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -18,6 +18,10 @@ interface EntryDao {
|
||||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY publishedAt DESC")
|
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY publishedAt DESC")
|
||||||
fun observeUnread(): Flow<List<EntryAndFeed>>
|
fun observeUnread(): Flow<List<EntryAndFeed>>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM Entry WHERE status = \"READ\" ORDER BY publishedAt DESC")
|
||||||
|
fun observeRead(): Flow<List<EntryAndFeed>>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
|
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
|
||||||
fun getAll(): List<EntryAndFeed>
|
fun getAll(): List<EntryAndFeed>
|
||||||
|
|
Loading…
Reference in a new issue