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.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)
|
||||||
|
|
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: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" />
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
|
@ -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
|
_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 {
|
||||||
|
_entry.value = it
|
||||||
_loading.value = false
|
_loading.value = false
|
||||||
entryRepository.markEntryRead(entry.entry)
|
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
|
||||||
|
@ -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() {
|
fun dismissError() {
|
||||||
_errorMessage.value = null
|
_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
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
{}, {}, {}, {}
|
{}, {}, {}, {}, {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
errorMessage?.let {
|
errorMessage?.let {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("Unable to load entry: $it")
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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,20 +29,34 @@ 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(
|
||||||
|
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) {
|
if (state is AuthViewModel.AuthState.Loading) {
|
||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
} else if (state is AuthViewModel.AuthState.Unauthenticated) {
|
} else if (state is AuthViewModel.AuthState.Unauthenticated) {
|
||||||
|
|
||||||
|
val s = state as AuthViewModel.AuthState.Unauthenticated
|
||||||
AuthForm(
|
AuthForm(
|
||||||
state.server,
|
s.server,
|
||||||
state.username,
|
s.username,
|
||||||
state.password,
|
s.password,
|
||||||
authViewModel::login,
|
authViewModel::login,
|
||||||
state.errorMessage
|
s.errorMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
@ -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)
|
|
@ -7,15 +7,15 @@ 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,
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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))
|
entryDao.insertAll(apiService.getEntry(id))
|
||||||
return entryDao.getAllByIds(id).first()
|
}
|
||||||
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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(
|
@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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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?
|
||||||
)
|
)
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
Loading…
Reference in a new issue