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

View file

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

View file

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

View file

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

View file

@ -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,16 +53,58 @@ fun EntryList(
onRefresh = onRefresh onRefresh = onRefresh
) { ) {
LazyColumn { LazyColumn {
items(entries) { entry -> items(entries, { entry -> entry.entry.id }) { entry ->
EntryListItem( val dismissState = rememberDismissState()
entry.entry, LaunchedEffect(key1 = dismissState.currentValue) {
entry.feed, when (dismissState.currentValue) {
onEntryItemClicked, DismissValue.DismissedToStart -> onToggleReadClicked(entry.entry)
onFeedClicked, DismissValue.DismissedToEnd -> {
onToggleReadClicked, onStarClicked(entry.entry)
onStarClicked, dismissState.reset()
onExternalLinkClicked }
) 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, 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)
) { ) {
Row { Column(
Text( modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
modifier = Modifier.weight(1f),
text = entry.title,
style = MaterialTheme.typography.h6
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) { ) {
TextButton( Row {
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(
text = feed.feed.title, modifier = Modifier.weight(1f),
style = MaterialTheme.typography.body2, text = entry.title,
color = MaterialTheme.colors.onSurface style = MaterialTheme.typography.h6
) )
} }
// Text(text = feed.category.title) Row(
} verticalAlignment = Alignment.CenterVertically,
Row( modifier = Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxWidth()
horizontalArrangement = Arrangement.SpaceBetween, ) {
modifier = Modifier.fillMaxWidth() TextButton(
) { onClick = { onFeedClicked(feed.feed) },
Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2) modifier = Modifier.padding(start = 0.dp),
Spacer(modifier = Modifier.width(8.dp)) ) {
Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2) feed.icon?.let {
IconButton(onClick = { onStarClicked(entry) }) { val bytes = Base64.decode(it.data.substringAfter(","), Base64.DEFAULT)
Icon( val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
imageVector = if (entry.starred) Icons.Filled.Star else Icons.Outlined.Star, ?.asImageBitmap()
contentDescription = if (entry.starred) "Starred" else "Not starred" ?: return@let
) Image(
} modifier = Modifier
val context = LocalContext.current .width(16.dp)
IconButton(onClick = { .height(16.dp),
val intent = Intent(Intent.ACTION_SEND).apply { bitmap = bitmap,
// TODO: Get base url from viewmodel or something contentDescription = null,
type = "text/plain" )
putExtra( Spacer(modifier = Modifier.width(8.dp))
Intent.EXTRA_TEXT, }
"https://wbrawner.com/entry/share/${entry.shareCode}" 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" mimeType = "image/png"
) )
), ),
{}, {}, {}, {}, {} {}, {}, {}, {}
) )
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
@ -20,6 +22,6 @@ class CategoryRepository @Inject constructor(
} }
suspend fun getById(id: Long): Category? = categoryDao.getAllByIds(id).firstOrNull() suspend fun getById(id: Long): Category? = categoryDao.getAllByIds(id).firstOrNull()
?: getAll(true) ?: getAll(true)
.firstOrNull { it.id == id } .firstOrNull { it.id == id }
} }

View file

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

View file

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

View file

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

View file

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

View file

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