Implement entry details, history, and starred screens

This commit is contained in:
William Brawner 2022-09-23 15:19:34 -06:00
parent 9383042f09
commit 09b3a21cbc
31 changed files with 1237 additions and 259 deletions

View file

@ -57,7 +57,9 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.swiperefresh)
implementation(libs.accompanist.webview)
implementation(libs.bundles.compose)
implementation(libs.lifecycle)
implementation(libs.hilt.android.core)

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.wbrawner.nanoflux">
<application android:networkSecurityConfig="@xml/network_security_config"></application>
</manifest>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>

View file

@ -18,6 +18,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:fitsSystemWindows="true"
android:theme="@style/Theme.Nanoflux.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -8,7 +8,9 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.view.WindowCompat
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.MainScreen
import com.wbrawner.nanoflux.ui.auth.AuthScreen
@ -22,15 +24,16 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val authenticated = authViewModel.state.collectAsState()
val authenticated by authViewModel.state.collectAsState()
NanofluxApp {
if (authenticated.value is AuthViewModel.AuthState.Authenticated) {
if (authenticated is AuthViewModel.AuthState.Authenticated) {
MainScreen(authViewModel)
} else {
AuthScreen(authViewModel)
}
}
}
WindowCompat.setDecorFitsSystemWindows(window, false)
}
}

View file

@ -19,5 +19,5 @@ suspend fun syncAll(
iconRepository.getFeedIcon(it.feed.id)
}
}
entryRepository.getAll(true, entryRepository.getLatestId())
entryRepository.getAll(true)
}

View file

@ -0,0 +1,59 @@
package com.wbrawner.nanoflux.data.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.nanoflux.network.repository.*
import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import com.wbrawner.nanoflux.syncAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
abstract class EntryListViewModel(
private val feedRepository: FeedRepository,
private val categoryRepository: CategoryRepository,
private val entryRepository: EntryRepository,
private val iconRepository: IconRepository,
private val logger: Timber.Tree,
) : ViewModel() {
private val _loading = MutableStateFlow(false)
val loading = _loading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage = _errorMessage.asStateFlow()
abstract val entries: Flow<List<EntryAndFeed>>
fun dismissError() {
_errorMessage.value = null
}
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(status: EntryStatus? = null) = viewModelScope.launch(Dispatchers.IO) {
_loading.emit(true)
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
_loading.emit(false)
}
fun toggleRead(entry: Entry) = viewModelScope.launch(Dispatchers.IO) {
if (entry.status == Entry.Status.READ) {
entryRepository.markEntryUnread(entry)
} else if (entry.status == Entry.Status.UNREAD) {
entryRepository.markEntryRead(entry)
}
}
fun toggleStar(entry: Entry) = viewModelScope.launch(Dispatchers.IO) {
entryRepository.toggleStar(entry)
}
}

View file

@ -35,10 +35,15 @@ class EntryViewModel @Inject constructor(
_loading.value = true
_errorMessage.value = null
try {
val entry = entryRepository.getEntry(id)
_entry.value = entry
var markedRead = false
entryRepository.getEntry(id).collect {
_entry.value = it
_loading.value = false
entryRepository.markEntryRead(entry.entry)
if (!markedRead) {
entryRepository.markEntryRead(it.entry)
markedRead = true
}
}
} catch (e: Exception) {
_errorMessage.value = e.message ?: "Error fetching entry"
_loading.value = false
@ -47,10 +52,38 @@ class EntryViewModel @Inject constructor(
}
}
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 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)
}
fun downloadOriginalContent(entry: Entry) {
viewModelScope.launch(Dispatchers.IO) {
try {
entryRepository.download(entry)
} catch (e: Exception) {
_errorMessage.emit("Failed to fetch original content: ${e.message}")
}
}
}
fun dismissError() {
_errorMessage.value = null
}
// TODO: Get Base URL
fun getShareUrl(entry: Entry) = "baseUrl/${entry.shareCode}"
}

View file

@ -0,0 +1,26 @@
package com.wbrawner.nanoflux.data.viewmodel
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 dagger.hilt.android.lifecycle.HiltViewModel
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class HistoryViewModel @Inject constructor(
feedRepository: FeedRepository,
categoryRepository: CategoryRepository,
entryRepository: EntryRepository,
iconRepository: IconRepository,
logger: Timber.Tree,
) : EntryListViewModel(
feedRepository,
categoryRepository,
entryRepository,
iconRepository,
logger
) {
override val entries = entryRepository.observeRead()
}

View file

@ -0,0 +1,26 @@
package com.wbrawner.nanoflux.data.viewmodel
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 dagger.hilt.android.lifecycle.HiltViewModel
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class StarredViewModel @Inject constructor(
feedRepository: FeedRepository,
categoryRepository: CategoryRepository,
entryRepository: EntryRepository,
iconRepository: IconRepository,
logger: Timber.Tree,
) : EntryListViewModel(
feedRepository,
categoryRepository,
entryRepository,
iconRepository,
logger
) {
override val entries = entryRepository.observeStarred()
}

View file

@ -1,64 +1,26 @@
package com.wbrawner.nanoflux.data.viewmodel
import androidx.lifecycle.ViewModel
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.syncAll
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class UnreadViewModel @Inject constructor(
private val feedRepository: FeedRepository,
private val categoryRepository: CategoryRepository,
private val entryRepository: EntryRepository,
private val iconRepository: IconRepository,
private val logger: Timber.Tree,
) : ViewModel() {
private val _loading = MutableStateFlow(false)
val loading = _loading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage = _errorMessage.asStateFlow()
val entries = entryRepository.observeUnread()
fun dismissError() {
_errorMessage.value = null
}
// TODO: Get Base URL
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)
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)
}
feedRepository: FeedRepository,
categoryRepository: CategoryRepository,
entryRepository: EntryRepository,
iconRepository: IconRepository,
logger: Timber.Tree,
) : EntryListViewModel(
feedRepository,
categoryRepository,
entryRepository,
iconRepository,
logger
) {
override val entries = entryRepository.observeUnread()
}

View file

@ -8,7 +8,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
@ -21,14 +21,20 @@ 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.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.storage.model.*
import com.wbrawner.nanoflux.ui.theme.Green700
import com.wbrawner.nanoflux.ui.theme.Yellow700
import java.util.*
import kotlin.math.roundToInt
@ -41,6 +47,7 @@ fun EntryList(
entries: List<EntryAndFeed>,
onEntryItemClicked: (entry: Entry) -> Unit,
onFeedClicked: (feed: Feed) -> Unit,
onCategoryClicked: (category: Category) -> Unit,
onToggleReadClicked: (entry: Entry) -> Unit,
onStarClicked: (entry: Entry) -> Unit,
onShareClicked: (entry: Entry) -> Unit,
@ -53,7 +60,7 @@ fun EntryList(
onRefresh = onRefresh
) {
LazyColumn {
items(entries, { entry -> entry.entry.id }) { entry ->
itemsIndexed(entries, { _, entry -> entry.entry.id }) { index, entry ->
val dismissState = rememberDismissState()
LaunchedEffect(key1 = dismissState.currentValue) {
when (dismissState.currentValue) {
@ -72,12 +79,12 @@ fun EntryList(
val (color, text, icon) = when (dismissState.dismissDirection) {
DismissDirection.StartToEnd ->
if (entry.entry.starred) {
Triple(Color.Yellow, "Unstarred", Icons.Outlined.Star)
Triple(Yellow700, "Unstarred", Icons.Outlined.Star)
} else {
Triple(Color.Yellow, "Starred", Icons.Filled.Star)
Triple(Yellow700, "Starred", Icons.Filled.Star)
}
DismissDirection.EndToStart -> Triple(
Color.Green,
Green700,
"Read",
Icons.Default.Email
)
@ -101,10 +108,14 @@ fun EntryList(
entry.feed,
onEntryItemClicked,
onFeedClicked = onFeedClicked,
onCategoryClicked = onCategoryClicked,
onExternalLinkClicked = onExternalLinkClicked,
onShareClicked = onShareClicked,
)
}
if (index < entries.lastIndex) {
Divider()
}
}
}
}
@ -116,6 +127,7 @@ fun EntryListItem(
feed: FeedCategoryIcon,
onEntryItemClicked: (entry: Entry) -> Unit,
onFeedClicked: (feed: Feed) -> Unit,
onCategoryClicked: (category: Category) -> Unit,
onExternalLinkClicked: (entry: Entry) -> Unit,
onShareClicked: (entry: Entry) -> Unit
) {
@ -125,54 +137,39 @@ fun EntryListItem(
color = MaterialTheme.colors.surface
) {
Column(
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.Center
) {
Row {
Text(
modifier = Modifier.weight(1f),
modifier = Modifier
.weight(1f),
text = entry.title,
style = MaterialTheme.typography.h6
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
FlowRow(
crossAxisAlignment = FlowCrossAxisAlignment.Center,
modifier = Modifier.fillMaxWidth(),
mainAxisSpacing = 8.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))
}
Text(
text = feed.feed.title,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface
)
}
// Text(text = feed.category.title)
FeedButton(feed.icon, feed.feed, onFeedClicked)
CategoryButton(feed.category, onCategoryClicked)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
) {
Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2)
val fontStyle = MaterialTheme.typography.body2.copy(
fontStyle = FontStyle.Italic,
fontSize = 12.sp
)
Text(text = entry.publishedAt.timeSince(), style = fontStyle)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2)
Text(text = "${entry.readingTime}m read", style = fontStyle)
IconButton(onClick = { onExternalLinkClicked(entry) }) {
Icon(
imageVector = Icons.Outlined.OpenInBrowser,
@ -190,6 +187,62 @@ fun EntryListItem(
}
}
@Composable
fun FeedButton(
icon: Feed.Icon?,
feed: Feed,
onFeedClicked: (feed: Feed) -> Unit
) {
TextButton(
onClick = { onFeedClicked(feed) },
contentPadding = PaddingValues(
start = 0.dp,
top = ButtonDefaults.ContentPadding.calculateTopPadding(),
end = 8.dp,
bottom = ButtonDefaults.ContentPadding.calculateBottomPadding()
)
) {
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.title,
style = MaterialTheme.typography.body2,
fontSize = 12.sp,
color = MaterialTheme.colors.onSurface
)
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CategoryButton(
category: Category,
onCategoryClicked: (category: Category) -> Unit
) {
Chip(
onClick = { onCategoryClicked(category) },
) {
Text(
text = category.title,
style = MaterialTheme.typography.body2,
fontSize = 12.sp,
color = MaterialTheme.colors.onSurface
)
}
}
@Composable
@Preview
fun EntryListItem_Preview() {
@ -237,12 +290,14 @@ fun EntryListItem_Preview() {
ignoreHttpCache = false,
fetchViaProxy = false,
categoryId = 0,
iconId = 0
iconId = 0,
hideGlobally = false
),
category = Category(
id = 2,
title = "Category Title",
userId = 0
userId = 0,
hideGlobally = false
),
icon = Feed.Icon(
id = 0,
@ -250,7 +305,7 @@ fun EntryListItem_Preview() {
mimeType = "image/png"
)
),
{}, {}, {}, {}
{}, {}, {}, {}, {}
)
}
}

View file

@ -9,26 +9,25 @@ 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
import com.wbrawner.nanoflux.data.viewmodel.UnreadViewModel
import com.wbrawner.nanoflux.data.viewmodel.EntryListViewModel
import kotlinx.coroutines.launch
@Composable
fun UnreadScreen(
fun EntryListScreen(
navController: NavController,
snackbarHostState: SnackbarHostState,
unreadViewModel: UnreadViewModel = viewModel()
entryListViewModel: EntryListViewModel
) {
val loading by unreadViewModel.loading.collectAsState()
val errorMessage by unreadViewModel.errorMessage.collectAsState()
val entries by unreadViewModel.entries.collectAsState(emptyList())
val loading by entryListViewModel.loading.collectAsState()
val errorMessage by entryListViewModel.errorMessage.collectAsState()
val entries by entryListViewModel.entries.collectAsState(emptyList())
val coroutineScope = rememberCoroutineScope()
errorMessage?.let {
coroutineScope.launch {
when (snackbarHostState.showSnackbar(it, "Retry")) {
SnackbarResult.ActionPerformed -> unreadViewModel.refresh()
else -> unreadViewModel.dismissError()
SnackbarResult.ActionPerformed -> entryListViewModel.refresh()
else -> entryListViewModel.dismissError()
}
}
}
@ -41,14 +40,17 @@ fun UnreadScreen(
onFeedClicked = {
navController.navigate("feeds/${it.id}")
},
onToggleReadClicked = { unreadViewModel.toggleRead(it) },
onStarClicked = { unreadViewModel.toggleStar(it) },
onCategoryClicked = {
navController.navigate("categories/${it.id}")
},
onToggleReadClicked = { entryListViewModel.toggleRead(it) },
onStarClicked = { entryListViewModel.toggleStar(it) },
onExternalLinkClicked = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url)))
},
onShareClicked = { entry ->
coroutineScope.launch {
unreadViewModel.share(entry)?.let { url ->
entryListViewModel.share(entry)?.let { url ->
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
@ -62,7 +64,7 @@ fun UnreadScreen(
},
isRefreshing = loading,
onRefresh = {
unreadViewModel.refresh()
entryListViewModel.refresh()
}
)
}

View file

@ -1,27 +1,44 @@
package com.wbrawner.nanoflux.ui
import android.text.Html
import android.widget.TextView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
import com.google.accompanist.flowlayout.FlowMainAxisAlignment
import com.google.accompanist.flowlayout.FlowRow
import com.wbrawner.nanoflux.BuildConfig
import com.wbrawner.nanoflux.data.viewmodel.EntryViewModel
import com.wbrawner.nanoflux.storage.model.Category
import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import com.wbrawner.nanoflux.storage.model.Feed
import kotlinx.coroutines.launch
@Composable
fun EntryScreen(entryId: Long, entryViewModel: EntryViewModel) {
fun EntryScreen(
entryId: Long,
navController: NavController,
entryViewModel: EntryViewModel,
setTitle: (String) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val loading by entryViewModel.loading.collectAsState()
val errorMessage by entryViewModel.errorMessage.collectAsState()
val entry by entryViewModel.entry.collectAsState()
@ -29,42 +46,188 @@ fun EntryScreen(entryId: Long, entryViewModel: EntryViewModel) {
entryViewModel.loadEntry(entryId)
}
if (loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
errorMessage?.let {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Unable to load entry: $it")
}
}
entry?.let {
Entry(it)
LaunchedEffect(it.entry.title) {
// TODO: Use Material3 to use collapsing toolbar
setTitle(it.entry.title)
}
EntryScreen(
entry = it,
onFeedClicked = { feed -> navController.navigate("feeds/${feed.id}") },
onCategoryClicked = { category -> navController.navigate("categories/${category.id}") },
onToggleReadClicked = entryViewModel::toggleRead,
onStarClicked = entryViewModel::toggleStar,
onShareClicked = { entry ->
coroutineScope.launch {
entryViewModel.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))
}
}
},
onExternalLinkClicked = { entry ->
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(entry.url)))
},
onDownloadClicked = entryViewModel::downloadOriginalContent
)
}
}
val cssOverride = """
<style>
:root {
--color-background: #FFFFFF;
--color-foreground: #000000;
--color-accent: #1976D2;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: #121212;
--color-foreground: #FFFFFF;
--color-accent: #90CAF9;
}
}
html, body {
background: var(--color-background);
color: var(--color-foreground);
}
a {
color: var(--color-accent);
}
img, video, iframe {
max-width: 100% !important;
height: auto !important;
}
</style>
""".trimIndent()
@Composable
fun Entry(entry: EntryAndFeed) {
fun EntryScreen(
entry: EntryAndFeed,
onFeedClicked: (feed: Feed) -> Unit,
onCategoryClicked: (category: Category) -> Unit,
onToggleReadClicked: (entry: Entry) -> Unit,
onStarClicked: (entry: Entry) -> Unit,
onShareClicked: (entry: Entry) -> Unit,
onExternalLinkClicked: (entry: Entry) -> Unit,
onDownloadClicked: (entry: Entry) -> Unit,
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
) {
EntryListItem(
entry = entry.entry,
feed = entry.feed,
onEntryItemClicked = { /*TODO*/ },
onFeedClicked = { /*TODO*/ },
onExternalLinkClicked = { /*TODO*/ },
onShareClicked = { /*TODO*/ },
)
// Adds view to Compose
AndroidView(
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
mainAxisSpacing = 8.dp,
mainAxisAlignment = FlowMainAxisAlignment.Start,
crossAxisAlignment = FlowCrossAxisAlignment.Center
) {
FeedButton(
icon = entry.feed.icon,
feed = entry.feed.feed,
onFeedClicked = onFeedClicked
)
CategoryButton(
category = entry.feed.category,
onCategoryClicked = onCategoryClicked
)
Spacer(modifier = Modifier.weight(1f))
val fontStyle = MaterialTheme.typography.body2.copy(
fontStyle = FontStyle.Italic,
fontSize = 12.sp
)
Text(text = entry.entry.publishedAt.timeSince(), style = fontStyle)
Text(text = "${entry.entry.readingTime}m read", style = fontStyle)
}
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { onToggleReadClicked(entry.entry) }) {
val (icon, description) = when (entry.entry.status) {
Entry.Status.UNREAD -> Icons.Outlined.Visibility to "Mark read"
else -> Icons.Outlined.VisibilityOff to "Make unread"
}
Icon(
imageVector = icon,
contentDescription = description
)
}
IconButton(onClick = { onStarClicked(entry.entry) }) {
Icon(
imageVector = Icons.Outlined.Star,
contentDescription = "Star"
)
}
IconButton(onClick = { onShareClicked(entry.entry) }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
}
IconButton(onClick = { onExternalLinkClicked(entry.entry) }) {
Icon(
imageVector = Icons.Outlined.OpenInBrowser,
contentDescription = "Open in browser"
)
}
IconButton(onClick = { onDownloadClicked(entry.entry) }) {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = "Download original content"
)
}
}
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
TextView(context).apply {
text = Html.fromHtml(entry.entry.content)
WebView(context).apply {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
settings.javaScriptEnabled = true
}
},
update = {
val baseUrl = Uri.parse(entry.entry.url)
.buildUpon()
.path("/")
.build()
.toString()
it.loadDataWithBaseURL(
baseUrl,
cssOverride + entry.entry.content,
"text/html",
null,
null
)
}
)
}
}

View file

@ -1,6 +1,5 @@
package com.wbrawner.nanoflux.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@ -10,8 +9,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@ -20,14 +21,14 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Constraints
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.SyncWorker
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
import com.wbrawner.nanoflux.data.viewmodel.*
import kotlinx.coroutines.launch
import java.util.*
import java.time.Duration
@Composable
fun MainScreen(
@ -36,8 +37,18 @@ fun MainScreen(
) {
val context = LocalContext.current
LaunchedEffect(key1 = context) {
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.setRequiresDeviceIdle(true)
.build()
val workRequest = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = Duration.ofHours(1),
flexTimeInterval = Duration.ofMinutes(45)
)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())
.enqueue(workRequest)
}
val navController = rememberNavController()
MainScaffold(
@ -64,62 +75,146 @@ fun MainScaffold(
val snackbarHostState = remember { SnackbarHostState() }
val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState)
val coroutineScope = rememberCoroutineScope()
val (title, setTitle) = remember { mutableStateOf("Unread") }
Scaffold(
scaffoldState = scaffoldState,
topBar = {
TopAppBar(
modifier = Modifier.statusBarsPadding(),
navigationIcon = {
IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
IconButton(onClick = {
coroutineScope.launch {
if (navController.currentDestination?.route?.startsWith("entries/") == true) {
navController.popBackStack()
} else {
scaffoldState.drawerState.open()
}
}
}) {
val icon =
if (navController.currentDestination?.route?.startsWith("entries/") == true) {
Icons.Default.ArrowBack
} else {
Icons.Default.Menu
}
Icon(
imageVector = Icons.Default.Menu,
imageVector = icon,
contentDescription = "Menu"
)
}
},
title = {
Text(
text = navController.currentDestination?.displayName?.capitalize(Locale.ENGLISH)
?: "Unread"
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
})
},
drawerContent = {
DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread")
DrawerButton(onClick = onStarredClicked, icon = Icons.Default.Star, text = "Starred")
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp
Row(Modifier.padding(top = topPadding, start = 8.dp, end = 8.dp, bottom = 8.dp)) {
Text(text = "nano", fontSize = 24.sp)
Text(text = "flux", color = MaterialTheme.colors.primary, fontSize = 24.sp)
}
DrawerButton(
onClick = onHistoryClicked,
onClick = {
onUnreadClicked()
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
icon = Icons.Default.Email, text = "Unread"
)
DrawerButton(
onClick = {
onStarredClicked()
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
icon = Icons.Default.Star,
text = "Starred"
)
DrawerButton(
onClick = {
onHistoryClicked()
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
icon = Icons.Default.DateRange,
text = "History"
)
DrawerButton(onClick = onFeedsClicked, icon = Icons.Default.List, text = "Feeds")
DrawerButton(
onClick = onCategoriesClicked,
onClick = {
onFeedsClicked()
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
icon = Icons.Default.List,
text = "Feeds"
)
DrawerButton(
onClick = {
onCategoriesClicked()
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
icon = Icons.Default.Info,
text = "Categories"
)
DrawerButton(
onClick = onSettingsClicked,
onClick = {
onSettingsClicked()
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
icon = Icons.Default.Settings,
text = "Settings"
)
}
) {
// TODO: Extract routes to constants
NavHost(navController, startDestination = "unread") {
NavHost(
modifier = Modifier.padding(it),
navController = navController,
startDestination = "unread"
) {
composable("unread") {
UnreadScreen(navController, snackbarHostState, hiltViewModel())
LaunchedEffect(navController.currentBackStackEntry) {
setTitle("Unread")
}
EntryListScreen(navController, snackbarHostState, hiltViewModel<UnreadViewModel>())
}
composable("starred") {
UnreadScreen(navController, snackbarHostState, hiltViewModel())
LaunchedEffect(navController.currentBackStackEntry) {
setTitle("Starred")
}
EntryListScreen(navController, snackbarHostState, hiltViewModel<StarredViewModel>())
}
composable("history") {
UnreadScreen(navController, snackbarHostState, hiltViewModel())
LaunchedEffect(navController.currentBackStackEntry) {
setTitle("History")
}
EntryListScreen(navController, snackbarHostState, hiltViewModel<HistoryViewModel>())
}
composable(
"entries/{entryId}",
arguments = listOf(navArgument("entryId") { type = NavType.LongType })
) {
EntryScreen(it.arguments!!.getLong("entryId"), hiltViewModel())
LaunchedEffect(navController.currentBackStackEntry) {
setTitle("")
}
EntryScreen(
it.arguments!!.getLong("entryId"),
navController,
hiltViewModel(),
setTitle,
)
}
}
}
@ -133,11 +228,10 @@ fun DrawerButton(onClick: () -> Unit, icon: ImageVector, text: String) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Image(
Icon(
imageVector = icon,
contentDescription = null, // decorative
// colorFilter = ColorFilter.tint(textIconColor),
// alpha = imageAlpha
contentDescription = null,
tint = MaterialTheme.colors.onSurface
)
Spacer(Modifier.width(16.dp))
Text(

View file

@ -7,16 +7,13 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@ -24,6 +21,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.theme.NanofluxTheme
@ -31,20 +29,34 @@ import timber.log.Timber
@Composable
fun AuthScreen(authViewModel: AuthViewModel) {
val vmState = authViewModel.state.collectAsState()
val state = vmState.value
val state by authViewModel.state.collectAsState()
Column(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
Modifier
.fillMaxWidth()
.padding(8.dp), horizontalArrangement = Arrangement.Center) {
Text(text = "nano", fontSize = 24.sp)
Text(text = "flux", color = MaterialTheme.colors.primary, fontSize = 24.sp)
}
if (state is AuthViewModel.AuthState.Loading) {
CircularProgressIndicator()
} else if (state is AuthViewModel.AuthState.Unauthenticated) {
val s = state as AuthViewModel.AuthState.Unauthenticated
AuthForm(
state.server,
state.username,
state.password,
s.server,
s.username,
s.password,
authViewModel::login,
state.errorMessage
s.errorMessage
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable

View file

@ -2,7 +2,9 @@ package com.wbrawner.nanoflux.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Green200 = Color(0xFFA5D6A7)
val Green500 = Color(0xFF4CAF50)
val Green700 = Color(0xFF388E3C)
val Yellow700 = Color(0xFFFBC02D)
val Blue200 = Color(0xFF90CAF9)
val Blue700 = Color(0xFF1976D2)

View file

@ -7,15 +7,15 @@ import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
primary = Green200,
primaryVariant = Green700,
secondary = Blue200
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
primary = Green500,
primaryVariant = Green700,
secondary = Blue700
/* Other default colors to override
background = Color.White,

View file

@ -1,16 +1,17 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!-- Base application theme. -->
<style name="Theme.Nanoflux" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorPrimary">@color/green_200</item>
<item name="colorPrimaryVariant">@color/green_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorSecondary">@color/blue_200</item>
<item name="colorSecondaryVariant">@color/blue_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<item name="android:statusBarColor">@color/status_dark</item>
<item name="android:windowTranslucentNavigation">true</item>
<!-- Customize your theme here. -->
</style>
</resources>

View file

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="green_200">#FFA5D6A7</color>
<color name="green_500">#FF4CAF50</color>
<color name="green_700">#FF388E3C</color>
<color name="blue_200">#FF90CAF9</color>
<color name="blue_700">#FF1976D2</color>
<color name="status_dark">#282828</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -1,16 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!-- Base application theme. -->
<style name="Theme.Nanoflux" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorPrimary">@color/green_500</item>
<item name="colorPrimaryVariant">@color/green_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorSecondary">@color/blue_200</item>
<item name="colorSecondaryVariant">@color/blue_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<item name="android:statusBarColor">?attr/colorPrimary</item>
<item name="android:navigationBarColor">#00000000</item>
<!-- Customize your theme here. -->
</style>

View file

@ -1,4 +1,5 @@
[versions]
accompanist = "0.26.3-beta"
androidx-core = "1.9.0"
androidx-appcompat = "1.5.1"
compose-ui = "1.2.1"
@ -18,7 +19,9 @@ versionName = "1.0"
work = "2.7.1"
[libraries]
accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version = "0.26.3-beta" }
accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" }
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
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" }
@ -45,10 +48,10 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "
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" }
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-okhttp = { module = "io.ktor:ktor-client-okhttp", 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" }
@ -62,6 +65,6 @@ work-test = { module = "androidx.work:work-testing", version.ref = "work" }
[bundles]
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"]
networking = ["kotlin-reflection", "kotlinx-serialization", "ktor-core", "ktor-okhttp", "ktor-content-negotiation", "ktor-json", "ktor-logging", "ktor-serialization"]

View file

@ -6,7 +6,7 @@ import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN
import com.wbrawner.nanoflux.storage.model.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
@ -94,6 +94,8 @@ interface MinifluxApiService {
suspend fun shareEntry(entry: Entry): HttpResponse
suspend fun fetchOriginalContent(entry: Entry): EntryContentResponse
suspend fun getCategories(): List<Category>
suspend fun createCategory(title: String): Category
@ -171,6 +173,9 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long)
@Serializable
data class EntryResponse(val total: Long, val entries: List<Entry>)
@Serializable
data class EntryContentResponse(val content: String)
@Serializable
data class UpdateEntryRequest(
@SerialName("entry_ids") val entryIds: List<Long>,
@ -182,7 +187,7 @@ class KtorMinifluxApiService @Inject constructor(
private val sharedPreferences: SharedPreferences,
private val _logger: Timber.Tree
) : MinifluxApiService {
private val client = HttpClient(CIO) {
private val client = HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json {
isLenient = true
@ -400,6 +405,16 @@ class KtorMinifluxApiService @Inject constructor(
}
}
override suspend fun fetchOriginalContent(entry: Entry): EntryContentResponse =
client.get(url("entries/${entry.id}/fetch-content")) {
headers {
header(
"Authorization",
sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString()
)
}
}.body()
override suspend fun getCategories(): List<Category> = client.get(url("categories")) {
headers {
header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())

View file

@ -20,6 +20,8 @@ class EntryRepository @Inject constructor(
private val entryDao: EntryDao,
private val logger: Timber.Tree
) {
fun observeRead(): Flow<List<EntryAndFeed>> = entryDao.observeRead()
fun observeStarred(): Flow<List<EntryAndFeed>> = entryDao.observeStarred()
fun observeUnread(): Flow<List<EntryAndFeed>> = entryDao.observeUnread()
fun getCount(): Long = entryDao.getCount()
@ -52,13 +54,12 @@ class EntryRepository @Inject constructor(
return entryDao.getAllUnread()
}
suspend fun getEntry(id: Long): EntryAndFeed {
entryDao.getAllByIds(id).firstOrNull()?.let {
return@getEntry it
}
suspend fun getEntry(id: Long): Flow<EntryAndFeed> {
val entry = entryDao.observe(id)
if (entryDao.get(id) == null) {
entryDao.insertAll(apiService.getEntry(id))
return entryDao.getAllByIds(id).first()
}
return entry
}
suspend fun markEntryRead(entry: Entry) {
@ -95,13 +96,18 @@ class EntryRepository @Inject constructor(
return response.headers[HttpHeaders.Location]
}
suspend fun download(entry: Entry) {
val response = apiService.fetchOriginalContent(entry)
entryDao.update(entry.copy(content = response.content))
}
fun getLatestId(): Long = entryDao.getLatestId()
private suspend fun getEntries(request: suspend (page: Int) -> EntryResponse) {
var page = 0
var totalPages = 1L
while (page++ < totalPages) {
val response = request(page)
while (page < totalPages) {
val response = request(page++)
entryDao.insertAll(
response.entries
)

View file

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "afcf22cfdfd6aec5ea5cb19ceaa9b3bc",
"identityHash": "d70fee9b0f6949ca4277985d65fedf40",
"entities": [
{
"tableName": "Feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT NOT NULL, `siteUrl` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `checkedAt` INTEGER NOT NULL, `etagHeader` TEXT NOT NULL, `lastModifiedHeader` TEXT NOT NULL, `parsingErrorMessage` TEXT NOT NULL, `parsingErrorCount` INTEGER NOT NULL, `scraperRules` TEXT NOT NULL, `rewriteRules` TEXT NOT NULL, `crawler` INTEGER NOT NULL, `blocklistRules` TEXT NOT NULL, `keeplistRules` TEXT NOT NULL, `userAgent` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `disabled` INTEGER NOT NULL, `ignoreHttpCache` INTEGER NOT NULL, `fetchViaProxy` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `iconId` INTEGER, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT NOT NULL, `siteUrl` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `checkedAt` INTEGER NOT NULL, `etagHeader` TEXT NOT NULL, `lastModifiedHeader` TEXT NOT NULL, `parsingErrorMessage` TEXT NOT NULL, `parsingErrorCount` INTEGER NOT NULL, `scraperRules` TEXT NOT NULL, `rewriteRules` TEXT NOT NULL, `crawler` INTEGER NOT NULL, `blocklistRules` TEXT NOT NULL, `keeplistRules` TEXT NOT NULL, `userAgent` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `disabled` INTEGER NOT NULL, `ignoreHttpCache` INTEGER NOT NULL, `fetchViaProxy` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `iconId` INTEGER, `hideGlobally` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@ -145,6 +145,12 @@
"columnName": "iconId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "hideGlobally",
"columnName": "hideGlobally",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
@ -268,7 +274,7 @@
},
{
"tableName": "Category",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `userId` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `userId` INTEGER NOT NULL, `hideGlobally` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@ -287,6 +293,12 @@
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hideGlobally",
"columnName": "hideGlobally",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
@ -444,7 +456,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'afcf22cfdfd6aec5ea5cb19ceaa9b3bc')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd70fee9b0f6949ca4277985d65fedf40')"
]
}
}

View file

@ -0,0 +1,462 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "d70fee9b0f6949ca4277985d65fedf40",
"entities": [
{
"tableName": "Feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT NOT NULL, `siteUrl` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `checkedAt` INTEGER NOT NULL, `etagHeader` TEXT NOT NULL, `lastModifiedHeader` TEXT NOT NULL, `parsingErrorMessage` TEXT NOT NULL, `parsingErrorCount` INTEGER NOT NULL, `scraperRules` TEXT NOT NULL, `rewriteRules` TEXT NOT NULL, `crawler` INTEGER NOT NULL, `blocklistRules` TEXT NOT NULL, `keeplistRules` TEXT NOT NULL, `userAgent` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `disabled` INTEGER NOT NULL, `ignoreHttpCache` INTEGER NOT NULL, `fetchViaProxy` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `iconId` INTEGER, `hideGlobally` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "siteUrl",
"columnName": "siteUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "feedUrl",
"columnName": "feedUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "etagHeader",
"columnName": "etagHeader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastModifiedHeader",
"columnName": "lastModifiedHeader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parsingErrorMessage",
"columnName": "parsingErrorMessage",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parsingErrorCount",
"columnName": "parsingErrorCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scraperRules",
"columnName": "scraperRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rewriteRules",
"columnName": "rewriteRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "crawler",
"columnName": "crawler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "blocklistRules",
"columnName": "blocklistRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "keeplistRules",
"columnName": "keeplistRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userAgent",
"columnName": "userAgent",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "disabled",
"columnName": "disabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ignoreHttpCache",
"columnName": "ignoreHttpCache",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fetchViaProxy",
"columnName": "fetchViaProxy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "iconId",
"columnName": "iconId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "hideGlobally",
"columnName": "hideGlobally",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Entry",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `feedId` INTEGER NOT NULL, `status` TEXT NOT NULL, `hash` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `commentsUrl` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, `author` TEXT NOT NULL, `shareCode` TEXT NOT NULL, `starred` INTEGER NOT NULL, `readingTime` INTEGER NOT NULL, `enclosures` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feedId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hash",
"columnName": "hash",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "commentsUrl",
"columnName": "commentsUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publishedAt",
"columnName": "publishedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shareCode",
"columnName": "shareCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "readingTime",
"columnName": "readingTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enclosures",
"columnName": "enclosures",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Category",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `userId` INTEGER NOT NULL, `hideGlobally` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hideGlobally",
"columnName": "hideGlobally",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Icon",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `data` TEXT NOT NULL, `mimeType` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "data",
"columnName": "data",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `username` TEXT NOT NULL, `admin` INTEGER NOT NULL, `theme` TEXT NOT NULL, `language` TEXT NOT NULL, `timezone` TEXT NOT NULL, `entrySortingDirection` TEXT NOT NULL, `stylesheet` TEXT NOT NULL, `googleId` TEXT NOT NULL, `openidConnectId` TEXT NOT NULL, `entriesPerPage` INTEGER NOT NULL, `keyboardShortcuts` INTEGER NOT NULL, `showReadingTime` INTEGER NOT NULL, `entrySwipe` INTEGER NOT NULL, `lastLoginAt` INTEGER NOT NULL, `displayMode` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "admin",
"columnName": "admin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "theme",
"columnName": "theme",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entrySortingDirection",
"columnName": "entrySortingDirection",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "stylesheet",
"columnName": "stylesheet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "googleId",
"columnName": "googleId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "openidConnectId",
"columnName": "openidConnectId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entriesPerPage",
"columnName": "entriesPerPage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyboardShortcuts",
"columnName": "keyboardShortcuts",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showReadingTime",
"columnName": "showReadingTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entrySwipe",
"columnName": "entrySwipe",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastLoginAt",
"columnName": "lastLoginAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayMode",
"columnName": "displayMode",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd70fee9b0f6949ca4277985d65fedf40')"
]
}
}

View file

@ -8,7 +8,7 @@ import java.util.*
@Database(
entities = [com.wbrawner.nanoflux.storage.model.Feed::class, com.wbrawner.nanoflux.storage.model.Entry::class, com.wbrawner.nanoflux.storage.model.Category::class, com.wbrawner.nanoflux.storage.model.Feed.Icon::class, com.wbrawner.nanoflux.storage.model.User::class],
version = 1,
version = 2,
exportSchema = true,
)
@TypeConverters(DateTypeConverter::class)

View file

@ -22,7 +22,9 @@ object StorageModule {
@Provides
@Singleton
fun providesNanofluxDatabase(@ApplicationContext context: Context): NanofluxDatabase =
Room.databaseBuilder(context, NanofluxDatabase::class.java, "nanoflux").build()
Room.databaseBuilder(context, NanofluxDatabase::class.java, "nanoflux")
.fallbackToDestructiveMigrationFrom(1)
.build()
@Provides
@Singleton

View file

@ -14,14 +14,26 @@ interface EntryDao {
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
fun observeAll(): Flow<List<EntryAndFeed>>
@Transaction
@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 Entry.publishedAt DESC")
fun observeStarred(): Flow<List<EntryAndFeed>>
@Transaction
@Query(
"""
SELECT * FROM Entry
INNER JOIN Feed on Feed.id = Entry.feedId
INNER JOIN Category on Category.id = Feed.categoryId
WHERE Entry.status = "UNREAD" AND Feed.hideGlobally = 0 AND Category.hideGlobally = 0
ORDER BY publishedAt DESC
"""
)
fun observeUnread(): Flow<List<EntryAndFeed>>
@Transaction
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
fun getAll(): List<EntryAndFeed>
@ -31,8 +43,12 @@ interface EntryDao {
fun getAllUnread(): List<EntryAndFeed>
@Transaction
@Query("SELECT * FROM Entry WHERE id in (:ids) ORDER BY createdAt DESC")
fun getAllByIds(vararg ids: Long): List<EntryAndFeed>
@Query("SELECT * FROM Entry WHERE id = :id")
fun observe(id: Long): Flow<EntryAndFeed>
@Transaction
@Query("SELECT * FROM Entry WHERE id = :id")
fun get(id: Long): EntryAndFeed?
@Transaction
@Query("SELECT * FROM Entry WHERE feedId in (:ids) ORDER BY createdAt DESC")

View file

@ -13,4 +13,6 @@ data class Category(
val title: String,
@SerialName("user_id")
val userId: Long,
@SerialName("hide_globally")
val hideGlobally: Boolean?
)

View file

@ -33,7 +33,8 @@ data class Feed(
val ignoreHttpCache: Boolean,
val fetchViaProxy: Boolean,
val categoryId: Long,
val iconId: Long?
val iconId: Long?,
val hideGlobally: Boolean?
) {
@Entity
@Serializable
@ -104,7 +105,9 @@ data class FeedJson(
val fetchViaProxy: Boolean,
val category: Category,
@SerialName("icon")
val icon: IconJson?
val icon: IconJson?,
@SerialName("hide_globally")
val hideGlobally: Boolean?
) {
@Serializable
data class IconJson(
@ -137,6 +140,7 @@ data class FeedJson(
ignoreHttpCache,
fetchViaProxy,
category.id,
icon?.iconId
icon?.iconId,
hideGlobally
)
}