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">
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
),
|
||||
{}, {}, {}, {}, {}
|
||||
{}, {}, {}, {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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 }
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue