Implement entry details, history, and starred screens
This commit is contained in:
parent
9383042f09
commit
09b3a21cbc
31 changed files with 1237 additions and 259 deletions
|
@ -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)
|
||||
|
|
6
app/src/debug/res/AndroidManifest.xml
Normal file
6
app/src/debug/res/AndroidManifest.xml
Normal 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>
|
8
app/src/debug/res/xml/network_security_config.xml
Normal file
8
app/src/debug/res/xml/network_security_config.xml
Normal 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>
|
|
@ -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" />
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,5 +19,5 @@ suspend fun syncAll(
|
|||
iconRepository.getFeedIcon(it.feed.id)
|
||||
}
|
||||
}
|
||||
entryRepository.getAll(true, entryRepository.getLatestId())
|
||||
entryRepository.getAll(true)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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}"
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
),
|
||||
{}, {}, {}, {}
|
||||
{}, {}, {}, {}, {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -13,4 +13,6 @@ data class Category(
|
|||
val title: String,
|
||||
@SerialName("user_id")
|
||||
val userId: Long,
|
||||
@SerialName("hide_globally")
|
||||
val hideGlobally: Boolean?
|
||||
)
|
|
@ -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
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue