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.core)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material) implementation(libs.material)
implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.swiperefresh) implementation(libs.accompanist.swiperefresh)
implementation(libs.accompanist.webview)
implementation(libs.bundles.compose) implementation(libs.bundles.compose)
implementation(libs.lifecycle) implementation(libs.lifecycle)
implementation(libs.hilt.android.core) 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:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:fitsSystemWindows="true"
android:theme="@style/Theme.Nanoflux.NoActionBar"> android:theme="@style/Theme.Nanoflux.NoActionBar">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <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.material.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.core.view.WindowCompat
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.MainScreen import com.wbrawner.nanoflux.ui.MainScreen
import com.wbrawner.nanoflux.ui.auth.AuthScreen import com.wbrawner.nanoflux.ui.auth.AuthScreen
@ -22,15 +24,16 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val authenticated = authViewModel.state.collectAsState() val authenticated by authViewModel.state.collectAsState()
NanofluxApp { NanofluxApp {
if (authenticated.value is AuthViewModel.AuthState.Authenticated) { if (authenticated is AuthViewModel.AuthState.Authenticated) {
MainScreen(authViewModel) MainScreen(authViewModel)
} else { } else {
AuthScreen(authViewModel) AuthScreen(authViewModel)
} }
} }
} }
WindowCompat.setDecorFitsSystemWindows(window, false)
} }
} }

View file

@ -19,5 +19,5 @@ suspend fun syncAll(
iconRepository.getFeedIcon(it.feed.id) 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,22 +35,55 @@ class EntryViewModel @Inject constructor(
_loading.value = true _loading.value = true
_errorMessage.value = null _errorMessage.value = null
try { try {
val entry = entryRepository.getEntry(id) var markedRead = false
_entry.value = entry entryRepository.getEntry(id).collect {
_loading.value = false _entry.value = it
entryRepository.markEntryRead(entry.entry) _loading.value = false
if (!markedRead) {
entryRepository.markEntryRead(it.entry)
markedRead = true
}
}
} catch (e: Exception) { } catch (e: Exception) {
_errorMessage.value = e.message?: "Error fetching entry" _errorMessage.value = e.message ?: "Error fetching entry"
_loading.value = false _loading.value = false
logger.e(e) logger.e(e)
} }
} }
} }
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() { fun dismissError() {
_errorMessage.value = null _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 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.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.network.repository.IconRepository
import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.syncAll
import dagger.hilt.android.lifecycle.HiltViewModel 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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class UnreadViewModel @Inject constructor( class UnreadViewModel @Inject constructor(
private val feedRepository: FeedRepository, feedRepository: FeedRepository,
private val categoryRepository: CategoryRepository, categoryRepository: CategoryRepository,
private val entryRepository: EntryRepository, entryRepository: EntryRepository,
private val iconRepository: IconRepository, iconRepository: IconRepository,
private val logger: Timber.Tree, logger: Timber.Tree,
) : ViewModel() { ) : EntryListViewModel(
private val _loading = MutableStateFlow(false) feedRepository,
val loading = _loading.asStateFlow() categoryRepository,
private val _errorMessage = MutableStateFlow<String?>(null) entryRepository,
val errorMessage = _errorMessage.asStateFlow() iconRepository,
val entries = entryRepository.observeUnread() logger
) {
fun dismissError() { override val entries = entryRepository.observeUnread()
_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)
}
} }

View file

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

View file

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

View file

@ -1,27 +1,44 @@
package com.wbrawner.nanoflux.ui package com.wbrawner.nanoflux.ui
import android.text.Html import android.content.Intent
import android.widget.TextView import android.net.Uri
import androidx.compose.foundation.layout.Column import android.webkit.WebView
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.*
import androidx.compose.material.Text import androidx.compose.material.icons.Icons
import androidx.compose.runtime.Composable import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.LaunchedEffect import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.*
import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.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.EntryAndFeed
import com.wbrawner.nanoflux.storage.model.Feed
import kotlinx.coroutines.launch
@Composable @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 loading by entryViewModel.loading.collectAsState()
val errorMessage by entryViewModel.errorMessage.collectAsState() val errorMessage by entryViewModel.errorMessage.collectAsState()
val entry by entryViewModel.entry.collectAsState() val entry by entryViewModel.entry.collectAsState()
@ -29,42 +46,188 @@ fun EntryScreen(entryId: Long, entryViewModel: EntryViewModel) {
entryViewModel.loadEntry(entryId) entryViewModel.loadEntry(entryId)
} }
if (loading) { if (loading) {
CircularProgressIndicator() Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} }
errorMessage?.let { errorMessage?.let {
Text("Unable to load entry: $it") Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Unable to load entry: $it")
}
} }
entry?.let { 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 @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() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
EntryListItem( Column(
entry = entry.entry,
feed = entry.feed,
onEntryItemClicked = { /*TODO*/ },
onFeedClicked = { /*TODO*/ },
onExternalLinkClicked = { /*TODO*/ },
onShareClicked = { /*TODO*/ },
)
// Adds view to Compose
AndroidView(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.padding(16.dp), .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 -> factory = { context ->
TextView(context).apply { WebView(context).apply {
text = Html.fromHtml(entry.entry.content) 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 package com.wbrawner.nanoflux.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -10,8 +9,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
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 androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@ -20,14 +21,14 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Constraints
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import com.wbrawner.nanoflux.NanofluxApp import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.SyncWorker import com.wbrawner.nanoflux.SyncWorker
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel import com.wbrawner.nanoflux.data.viewmodel.*
import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.time.Duration
@Composable @Composable
fun MainScreen( fun MainScreen(
@ -36,8 +37,18 @@ fun MainScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(key1 = context) { 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) WorkManager.getInstance(context)
.enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build()) .enqueue(workRequest)
} }
val navController = rememberNavController() val navController = rememberNavController()
MainScaffold( MainScaffold(
@ -64,62 +75,146 @@ fun MainScaffold(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState) val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val (title, setTitle) = remember { mutableStateOf("Unread") }
Scaffold( Scaffold(
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
topBar = { topBar = {
TopAppBar( TopAppBar(
modifier = Modifier.statusBarsPadding(),
navigationIcon = { 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( Icon(
imageVector = Icons.Default.Menu, imageVector = icon,
contentDescription = "Menu" contentDescription = "Menu"
) )
} }
}, },
title = { title = {
Text( Text(
text = navController.currentDestination?.displayName?.capitalize(Locale.ENGLISH) text = title,
?: "Unread" maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
}) })
}, },
drawerContent = { drawerContent = {
DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread") val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp
DrawerButton(onClick = onStarredClicked, icon = Icons.Default.Star, text = "Starred") 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( 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, icon = Icons.Default.DateRange,
text = "History" text = "History"
) )
DrawerButton(onClick = onFeedsClicked, icon = Icons.Default.List, text = "Feeds")
DrawerButton( 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, icon = Icons.Default.Info,
text = "Categories" text = "Categories"
) )
DrawerButton( DrawerButton(
onClick = onSettingsClicked, onClick = {
onSettingsClicked()
coroutineScope.launch {
scaffoldState.drawerState.close()
}
},
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
text = "Settings" text = "Settings"
) )
} }
) { ) {
// TODO: Extract routes to constants // TODO: Extract routes to constants
NavHost(navController, startDestination = "unread") { NavHost(
modifier = Modifier.padding(it),
navController = navController,
startDestination = "unread"
) {
composable("unread") { composable("unread") {
UnreadScreen(navController, snackbarHostState, hiltViewModel()) LaunchedEffect(navController.currentBackStackEntry) {
setTitle("Unread")
}
EntryListScreen(navController, snackbarHostState, hiltViewModel<UnreadViewModel>())
} }
composable("starred") { composable("starred") {
UnreadScreen(navController, snackbarHostState, hiltViewModel()) LaunchedEffect(navController.currentBackStackEntry) {
setTitle("Starred")
}
EntryListScreen(navController, snackbarHostState, hiltViewModel<StarredViewModel>())
} }
composable("history") { composable("history") {
UnreadScreen(navController, snackbarHostState, hiltViewModel()) LaunchedEffect(navController.currentBackStackEntry) {
setTitle("History")
}
EntryListScreen(navController, snackbarHostState, hiltViewModel<HistoryViewModel>())
} }
composable( composable(
"entries/{entryId}", "entries/{entryId}",
arguments = listOf(navArgument("entryId") { type = NavType.LongType }) 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, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Image( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, // decorative contentDescription = null,
// colorFilter = ColorFilter.tint(textIconColor), tint = MaterialTheme.colors.onSurface
// alpha = imageAlpha
) )
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Text( Text(

View file

@ -7,16 +7,13 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.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.TextStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType 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.text.style.TextAlign
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 androidx.compose.ui.unit.sp
import com.wbrawner.nanoflux.NanofluxApp import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.theme.NanofluxTheme import com.wbrawner.nanoflux.ui.theme.NanofluxTheme
@ -31,18 +29,32 @@ import timber.log.Timber
@Composable @Composable
fun AuthScreen(authViewModel: AuthViewModel) { fun AuthScreen(authViewModel: AuthViewModel) {
val vmState = authViewModel.state.collectAsState() val state by authViewModel.state.collectAsState()
val state = vmState.value Column(
if (state is AuthViewModel.AuthState.Loading) { Modifier.fillMaxSize(),
CircularProgressIndicator() verticalArrangement = Arrangement.Center,
} else if (state is AuthViewModel.AuthState.Unauthenticated) { horizontalAlignment = Alignment.CenterHorizontally
AuthForm( ) {
state.server, Row(
state.username, Modifier
state.password, .fillMaxWidth()
authViewModel::login, .padding(8.dp), horizontalArrangement = Arrangement.Center) {
state.errorMessage 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(
s.server,
s.username,
s.password,
authViewModel::login,
s.errorMessage
)
}
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
[versions] [versions]
accompanist = "0.26.3-beta"
androidx-core = "1.9.0" androidx-core = "1.9.0"
androidx-appcompat = "1.5.1" androidx-appcompat = "1.5.1"
compose-ui = "1.2.1" compose-ui = "1.2.1"
@ -18,7 +19,9 @@ 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-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-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" }
@ -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" } 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-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-okhttp = { module = "io.ktor:ktor-client-okhttp", 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" }
@ -62,6 +65,6 @@ work-test = { module = "androidx.work:work-testing", version.ref = "work" }
[bundles] [bundles]
compose = ["compose-compiler", "compose-ui", "compose-material", "compose-icons", "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-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 com.wbrawner.nanoflux.storage.model.*
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* 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.contentnegotiation.*
import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.logging.*
import io.ktor.client.request.* import io.ktor.client.request.*
@ -94,6 +94,8 @@ interface MinifluxApiService {
suspend fun shareEntry(entry: Entry): HttpResponse suspend fun shareEntry(entry: Entry): HttpResponse
suspend fun fetchOriginalContent(entry: Entry): EntryContentResponse
suspend fun getCategories(): List<Category> suspend fun getCategories(): List<Category>
suspend fun createCategory(title: String): Category suspend fun createCategory(title: String): Category
@ -171,6 +173,9 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long)
@Serializable @Serializable
data class EntryResponse(val total: Long, val entries: List<Entry>) data class EntryResponse(val total: Long, val entries: List<Entry>)
@Serializable
data class EntryContentResponse(val content: String)
@Serializable @Serializable
data class UpdateEntryRequest( data class UpdateEntryRequest(
@SerialName("entry_ids") val entryIds: List<Long>, @SerialName("entry_ids") val entryIds: List<Long>,
@ -182,7 +187,7 @@ class KtorMinifluxApiService @Inject constructor(
private val sharedPreferences: SharedPreferences, private val sharedPreferences: SharedPreferences,
private val _logger: Timber.Tree private val _logger: Timber.Tree
) : MinifluxApiService { ) : MinifluxApiService {
private val client = HttpClient(CIO) { private val client = HttpClient(OkHttp) {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
isLenient = true 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")) { 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

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

View file

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "afcf22cfdfd6aec5ea5cb19ceaa9b3bc", "identityHash": "d70fee9b0f6949ca4277985d65fedf40",
"entities": [ "entities": [
{ {
"tableName": "Feed", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -145,6 +145,12 @@
"columnName": "iconId", "columnName": "iconId",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": false "notNull": false
},
{
"fieldPath": "hideGlobally",
"columnName": "hideGlobally",
"affinity": "INTEGER",
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@ -268,7 +274,7 @@
}, },
{ {
"tableName": "Category", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -287,6 +293,12 @@
"columnName": "userId", "columnName": "userId",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
},
{
"fieldPath": "hideGlobally",
"columnName": "hideGlobally",
"affinity": "INTEGER",
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@ -444,7 +456,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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( @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], 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, exportSchema = true,
) )
@TypeConverters(DateTypeConverter::class) @TypeConverters(DateTypeConverter::class)

View file

@ -22,7 +22,9 @@ object StorageModule {
@Provides @Provides
@Singleton @Singleton
fun providesNanofluxDatabase(@ApplicationContext context: Context): NanofluxDatabase = 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 @Provides
@Singleton @Singleton

View file

@ -14,14 +14,26 @@ interface EntryDao {
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC") @Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
fun observeAll(): Flow<List<EntryAndFeed>> fun observeAll(): Flow<List<EntryAndFeed>>
@Transaction
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY publishedAt DESC")
fun observeUnread(): Flow<List<EntryAndFeed>>
@Transaction @Transaction
@Query("SELECT * FROM Entry WHERE status = \"READ\" ORDER BY publishedAt DESC") @Query("SELECT * FROM Entry WHERE status = \"READ\" ORDER BY publishedAt DESC")
fun observeRead(): Flow<List<EntryAndFeed>> 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 @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>
@ -31,8 +43,12 @@ interface EntryDao {
fun getAllUnread(): List<EntryAndFeed> fun getAllUnread(): List<EntryAndFeed>
@Transaction @Transaction
@Query("SELECT * FROM Entry WHERE id in (:ids) ORDER BY createdAt DESC") @Query("SELECT * FROM Entry WHERE id = :id")
fun getAllByIds(vararg ids: Long): List<EntryAndFeed> fun observe(id: Long): Flow<EntryAndFeed>
@Transaction
@Query("SELECT * FROM Entry WHERE id = :id")
fun get(id: Long): EntryAndFeed?
@Transaction @Transaction
@Query("SELECT * FROM Entry WHERE feedId in (:ids) ORDER BY createdAt DESC") @Query("SELECT * FROM Entry WHERE feedId in (:ids) ORDER BY createdAt DESC")

View file

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

View file

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