diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9979963..a84f5e6 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -57,7 +57,9 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.material)
+ implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.swiperefresh)
+ implementation(libs.accompanist.webview)
implementation(libs.bundles.compose)
implementation(libs.lifecycle)
implementation(libs.hilt.android.core)
diff --git a/app/src/debug/res/AndroidManifest.xml b/app/src/debug/res/AndroidManifest.xml
new file mode 100644
index 0000000..cbb42df
--- /dev/null
+++ b/app/src/debug/res/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml
new file mode 100644
index 0000000..d238b2a
--- /dev/null
+++ b/app/src/debug/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d47581a..b0b8a41 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -18,6 +18,7 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
+ android:fitsSystemWindows="true"
android:theme="@style/Theme.Nanoflux.NoActionBar">
diff --git a/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt b/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt
index f4a0ce8..40b0f38 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/MainActivity.kt
@@ -8,7 +8,9 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
+import androidx.core.view.WindowCompat
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.MainScreen
import com.wbrawner.nanoflux.ui.auth.AuthScreen
@@ -22,15 +24,16 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
- val authenticated = authViewModel.state.collectAsState()
+ val authenticated by authViewModel.state.collectAsState()
NanofluxApp {
- if (authenticated.value is AuthViewModel.AuthState.Authenticated) {
+ if (authenticated is AuthViewModel.AuthState.Authenticated) {
MainScreen(authViewModel)
} else {
AuthScreen(authViewModel)
}
}
}
+ WindowCompat.setDecorFitsSystemWindows(window, false)
}
}
diff --git a/app/src/main/java/com/wbrawner/nanoflux/Utils.kt b/app/src/main/java/com/wbrawner/nanoflux/Utils.kt
index 2d0f776..46cc5f1 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/Utils.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/Utils.kt
@@ -19,5 +19,5 @@ suspend fun syncAll(
iconRepository.getFeedIcon(it.feed.id)
}
}
- entryRepository.getAll(true, entryRepository.getLatestId())
+ entryRepository.getAll(true)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryListViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryListViewModel.kt
new file mode 100644
index 0000000..f9ad147
--- /dev/null
+++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryListViewModel.kt
@@ -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(null)
+ val errorMessage = _errorMessage.asStateFlow()
+ abstract val entries: Flow>
+
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryViewModel.kt
index c063044..6ee0f75 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryViewModel.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/EntryViewModel.kt
@@ -35,22 +35,55 @@ class EntryViewModel @Inject constructor(
_loading.value = true
_errorMessage.value = null
try {
- val entry = entryRepository.getEntry(id)
- _entry.value = entry
- _loading.value = false
- entryRepository.markEntryRead(entry.entry)
+ var markedRead = false
+ entryRepository.getEntry(id).collect {
+ _entry.value = it
+ _loading.value = false
+ if (!markedRead) {
+ entryRepository.markEntryRead(it.entry)
+ markedRead = true
+ }
+ }
} catch (e: Exception) {
- _errorMessage.value = e.message?: "Error fetching entry"
+ _errorMessage.value = e.message ?: "Error fetching entry"
_loading.value = false
logger.e(e)
}
}
}
+ suspend fun share(entry: Entry): String? {
+ // Miniflux API doesn't currently support sharing so we have to send the external link for now
+ // https://github.com/miniflux/v2/issues/844
+// return withContext(Dispatchers.IO) {
+// entryRepository.share(entry)
+// }
+ return entry.url
+ }
+
+ fun toggleRead(entry: Entry) = viewModelScope.launch(Dispatchers.IO) {
+ if (entry.status == Entry.Status.READ) {
+ entryRepository.markEntryUnread(entry)
+ } else if (entry.status == Entry.Status.UNREAD) {
+ entryRepository.markEntryRead(entry)
+ }
+ }
+
+ fun toggleStar(entry: Entry) = viewModelScope.launch(Dispatchers.IO) {
+ entryRepository.toggleStar(entry)
+ }
+
+ fun downloadOriginalContent(entry: Entry) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ entryRepository.download(entry)
+ } catch (e: Exception) {
+ _errorMessage.emit("Failed to fetch original content: ${e.message}")
+ }
+ }
+ }
+
fun dismissError() {
_errorMessage.value = null
}
-
- // TODO: Get Base URL
- fun getShareUrl(entry: Entry) = "baseUrl/${entry.shareCode}"
}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/HistoryViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/HistoryViewModel.kt
new file mode 100644
index 0000000..7c981f8
--- /dev/null
+++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/HistoryViewModel.kt
@@ -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()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/StarredViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/StarredViewModel.kt
new file mode 100644
index 0000000..e5da54a
--- /dev/null
+++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/StarredViewModel.kt
@@ -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()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt
index d6b1d23..5b181e4 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/data/viewmodel/UnreadViewModel.kt
@@ -1,64 +1,26 @@
package com.wbrawner.nanoflux.data.viewmodel
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
import com.wbrawner.nanoflux.network.repository.CategoryRepository
import com.wbrawner.nanoflux.network.repository.EntryRepository
import com.wbrawner.nanoflux.network.repository.FeedRepository
import com.wbrawner.nanoflux.network.repository.IconRepository
-import com.wbrawner.nanoflux.storage.model.Entry
-import com.wbrawner.nanoflux.syncAll
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class UnreadViewModel @Inject constructor(
- private val feedRepository: FeedRepository,
- private val categoryRepository: CategoryRepository,
- private val entryRepository: EntryRepository,
- private val iconRepository: IconRepository,
- private val logger: Timber.Tree,
-) : ViewModel() {
- private val _loading = MutableStateFlow(false)
- val loading = _loading.asStateFlow()
- private val _errorMessage = MutableStateFlow(null)
- val errorMessage = _errorMessage.asStateFlow()
- val entries = entryRepository.observeUnread()
-
- fun dismissError() {
- _errorMessage.value = null
- }
-
- // TODO: Get Base URL
- suspend fun share(entry: Entry): String? {
- // Miniflux API doesn't currently support sharing so we have to send the external link for now
- // https://github.com/miniflux/v2/issues/844
-// return withContext(Dispatchers.IO) {
-// entryRepository.share(entry)
-// }
- return entry.url
- }
-
- fun refresh() = viewModelScope.launch(Dispatchers.IO) {
- _loading.emit(true)
- syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
- _loading.emit(false)
- }
-
- fun toggleRead(entry: Entry) = viewModelScope.launch(Dispatchers.IO) {
- if (entry.status == Entry.Status.READ) {
- entryRepository.markEntryUnread(entry)
- } else if (entry.status == Entry.Status.UNREAD) {
- entryRepository.markEntryRead(entry)
- }
- }
-
- fun toggleStar(entry: Entry) = viewModelScope.launch(Dispatchers.IO) {
- entryRepository.toggleStar(entry)
- }
+ feedRepository: FeedRepository,
+ categoryRepository: CategoryRepository,
+ entryRepository: EntryRepository,
+ iconRepository: IconRepository,
+ logger: Timber.Tree,
+) : EntryListViewModel(
+ feedRepository,
+ categoryRepository,
+ entryRepository,
+ iconRepository,
+ logger
+) {
+ override val entries = entryRepository.observeUnread()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt
index d467371..f9997d4 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/Entries.kt
@@ -8,7 +8,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
@@ -21,14 +21,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
+import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.storage.model.*
+import com.wbrawner.nanoflux.ui.theme.Green700
+import com.wbrawner.nanoflux.ui.theme.Yellow700
import java.util.*
import kotlin.math.roundToInt
@@ -41,6 +47,7 @@ fun EntryList(
entries: List,
onEntryItemClicked: (entry: Entry) -> Unit,
onFeedClicked: (feed: Feed) -> Unit,
+ onCategoryClicked: (category: Category) -> Unit,
onToggleReadClicked: (entry: Entry) -> Unit,
onStarClicked: (entry: Entry) -> Unit,
onShareClicked: (entry: Entry) -> Unit,
@@ -53,7 +60,7 @@ fun EntryList(
onRefresh = onRefresh
) {
LazyColumn {
- items(entries, { entry -> entry.entry.id }) { entry ->
+ itemsIndexed(entries, { _, entry -> entry.entry.id }) { index, entry ->
val dismissState = rememberDismissState()
LaunchedEffect(key1 = dismissState.currentValue) {
when (dismissState.currentValue) {
@@ -72,12 +79,12 @@ fun EntryList(
val (color, text, icon) = when (dismissState.dismissDirection) {
DismissDirection.StartToEnd ->
if (entry.entry.starred) {
- Triple(Color.Yellow, "Unstarred", Icons.Outlined.Star)
+ Triple(Yellow700, "Unstarred", Icons.Outlined.Star)
} else {
- Triple(Color.Yellow, "Starred", Icons.Filled.Star)
+ Triple(Yellow700, "Starred", Icons.Filled.Star)
}
DismissDirection.EndToStart -> Triple(
- Color.Green,
+ Green700,
"Read",
Icons.Default.Email
)
@@ -101,10 +108,14 @@ fun EntryList(
entry.feed,
onEntryItemClicked,
onFeedClicked = onFeedClicked,
+ onCategoryClicked = onCategoryClicked,
onExternalLinkClicked = onExternalLinkClicked,
onShareClicked = onShareClicked,
)
}
+ if (index < entries.lastIndex) {
+ Divider()
+ }
}
}
}
@@ -116,6 +127,7 @@ fun EntryListItem(
feed: FeedCategoryIcon,
onEntryItemClicked: (entry: Entry) -> Unit,
onFeedClicked: (feed: Feed) -> Unit,
+ onCategoryClicked: (category: Category) -> Unit,
onExternalLinkClicked: (entry: Entry) -> Unit,
onShareClicked: (entry: Entry) -> Unit
) {
@@ -125,54 +137,39 @@ fun EntryListItem(
color = MaterialTheme.colors.surface
) {
Column(
- modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
+ modifier = Modifier.padding(8.dp),
+ verticalArrangement = Arrangement.Center
) {
Row {
Text(
- modifier = Modifier.weight(1f),
+ modifier = Modifier
+ .weight(1f),
text = entry.title,
- style = MaterialTheme.typography.h6
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
)
}
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
+ FlowRow(
+ crossAxisAlignment = FlowCrossAxisAlignment.Center,
+ modifier = Modifier.fillMaxWidth(),
+ mainAxisSpacing = 8.dp
) {
- TextButton(
- onClick = { onFeedClicked(feed.feed) },
- modifier = Modifier.padding(start = 0.dp),
- ) {
- feed.icon?.let {
- val bytes = Base64.decode(it.data.substringAfter(","), Base64.DEFAULT)
- val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
- ?.asImageBitmap()
- ?: return@let
- Image(
- modifier = Modifier
- .width(16.dp)
- .height(16.dp),
- bitmap = bitmap,
- contentDescription = null,
- )
- Spacer(modifier = Modifier.width(8.dp))
- }
- Text(
- text = feed.feed.title,
- style = MaterialTheme.typography.body2,
- color = MaterialTheme.colors.onSurface
- )
- }
-// Text(text = feed.category.title)
+ FeedButton(feed.icon, feed.feed, onFeedClicked)
+ CategoryButton(feed.category, onCategoryClicked)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier
+ .fillMaxWidth()
) {
- Text(text = entry.publishedAt.timeSince(), style = MaterialTheme.typography.body2)
+ val fontStyle = MaterialTheme.typography.body2.copy(
+ fontStyle = FontStyle.Italic,
+ fontSize = 12.sp
+ )
+ Text(text = entry.publishedAt.timeSince(), style = fontStyle)
Spacer(modifier = Modifier.width(8.dp))
- Text(text = "${entry.readingTime}m read", style = MaterialTheme.typography.body2)
+ Text(text = "${entry.readingTime}m read", style = fontStyle)
IconButton(onClick = { onExternalLinkClicked(entry) }) {
Icon(
imageVector = Icons.Outlined.OpenInBrowser,
@@ -190,6 +187,62 @@ fun EntryListItem(
}
}
+@Composable
+fun FeedButton(
+ icon: Feed.Icon?,
+ feed: Feed,
+ onFeedClicked: (feed: Feed) -> Unit
+) {
+ TextButton(
+ onClick = { onFeedClicked(feed) },
+ contentPadding = PaddingValues(
+ start = 0.dp,
+ top = ButtonDefaults.ContentPadding.calculateTopPadding(),
+ end = 8.dp,
+ bottom = ButtonDefaults.ContentPadding.calculateBottomPadding()
+ )
+ ) {
+ icon?.let {
+ val bytes = Base64.decode(it.data.substringAfter(","), Base64.DEFAULT)
+ val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ ?.asImageBitmap()
+ ?: return@let
+ Image(
+ modifier = Modifier
+ .width(16.dp)
+ .height(16.dp),
+ bitmap = bitmap,
+ contentDescription = null,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(
+ text = feed.title,
+ style = MaterialTheme.typography.body2,
+ fontSize = 12.sp,
+ color = MaterialTheme.colors.onSurface
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+fun CategoryButton(
+ category: Category,
+ onCategoryClicked: (category: Category) -> Unit
+) {
+ Chip(
+ onClick = { onCategoryClicked(category) },
+ ) {
+ Text(
+ text = category.title,
+ style = MaterialTheme.typography.body2,
+ fontSize = 12.sp,
+ color = MaterialTheme.colors.onSurface
+ )
+ }
+}
+
@Composable
@Preview
fun EntryListItem_Preview() {
@@ -237,12 +290,14 @@ fun EntryListItem_Preview() {
ignoreHttpCache = false,
fetchViaProxy = false,
categoryId = 0,
- iconId = 0
+ iconId = 0,
+ hideGlobally = false
),
category = Category(
id = 2,
title = "Category Title",
- userId = 0
+ userId = 0,
+ hideGlobally = false
),
icon = Feed.Icon(
id = 0,
@@ -250,7 +305,7 @@ fun EntryListItem_Preview() {
mimeType = "image/png"
)
),
- {}, {}, {}, {}
+ {}, {}, {}, {}, {}
)
}
}
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryListScreen.kt
similarity index 68%
rename from app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt
rename to app/src/main/java/com/wbrawner/nanoflux/ui/EntryListScreen.kt
index 8cf5fa8..315d3da 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryListScreen.kt
@@ -9,26 +9,25 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
-import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import com.wbrawner.nanoflux.data.viewmodel.UnreadViewModel
+import com.wbrawner.nanoflux.data.viewmodel.EntryListViewModel
import kotlinx.coroutines.launch
@Composable
-fun UnreadScreen(
+fun EntryListScreen(
navController: NavController,
snackbarHostState: SnackbarHostState,
- unreadViewModel: UnreadViewModel = viewModel()
+ entryListViewModel: EntryListViewModel
) {
- val loading by unreadViewModel.loading.collectAsState()
- val errorMessage by unreadViewModel.errorMessage.collectAsState()
- val entries by unreadViewModel.entries.collectAsState(emptyList())
+ val loading by entryListViewModel.loading.collectAsState()
+ val errorMessage by entryListViewModel.errorMessage.collectAsState()
+ val entries by entryListViewModel.entries.collectAsState(emptyList())
val coroutineScope = rememberCoroutineScope()
errorMessage?.let {
coroutineScope.launch {
when (snackbarHostState.showSnackbar(it, "Retry")) {
- SnackbarResult.ActionPerformed -> unreadViewModel.refresh()
- else -> unreadViewModel.dismissError()
+ SnackbarResult.ActionPerformed -> entryListViewModel.refresh()
+ else -> entryListViewModel.dismissError()
}
}
}
@@ -41,14 +40,17 @@ fun UnreadScreen(
onFeedClicked = {
navController.navigate("feeds/${it.id}")
},
- onToggleReadClicked = { unreadViewModel.toggleRead(it) },
- onStarClicked = { unreadViewModel.toggleStar(it) },
+ onCategoryClicked = {
+ navController.navigate("categories/${it.id}")
+ },
+ onToggleReadClicked = { entryListViewModel.toggleRead(it) },
+ onStarClicked = { entryListViewModel.toggleStar(it) },
onExternalLinkClicked = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it.url)))
},
onShareClicked = { entry ->
coroutineScope.launch {
- unreadViewModel.share(entry)?.let { url ->
+ entryListViewModel.share(entry)?.let { url ->
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
@@ -62,7 +64,7 @@ fun UnreadScreen(
},
isRefreshing = loading,
onRefresh = {
- unreadViewModel.refresh()
+ entryListViewModel.refresh()
}
)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt
index c979313..6e5364b 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt
@@ -1,27 +1,44 @@
package com.wbrawner.nanoflux.ui
-import android.text.Html
-import android.widget.TextView
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
+import android.content.Intent
+import android.net.Uri
+import android.webkit.WebView
+import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.CircularProgressIndicator
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
+import androidx.compose.material.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
+import androidx.navigation.NavController
+import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
+import com.google.accompanist.flowlayout.FlowMainAxisAlignment
+import com.google.accompanist.flowlayout.FlowRow
+import com.wbrawner.nanoflux.BuildConfig
import com.wbrawner.nanoflux.data.viewmodel.EntryViewModel
+import com.wbrawner.nanoflux.storage.model.Category
+import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
+import com.wbrawner.nanoflux.storage.model.Feed
+import kotlinx.coroutines.launch
@Composable
-fun EntryScreen(entryId: Long, entryViewModel: EntryViewModel) {
+fun EntryScreen(
+ entryId: Long,
+ navController: NavController,
+ entryViewModel: EntryViewModel,
+ setTitle: (String) -> Unit,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
val loading by entryViewModel.loading.collectAsState()
val errorMessage by entryViewModel.errorMessage.collectAsState()
val entry by entryViewModel.entry.collectAsState()
@@ -29,42 +46,188 @@ fun EntryScreen(entryId: Long, entryViewModel: EntryViewModel) {
entryViewModel.loadEntry(entryId)
}
if (loading) {
- CircularProgressIndicator()
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
}
errorMessage?.let {
- Text("Unable to load entry: $it")
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("Unable to load entry: $it")
+ }
}
entry?.let {
- Entry(it)
+ LaunchedEffect(it.entry.title) {
+ // TODO: Use Material3 to use collapsing toolbar
+ setTitle(it.entry.title)
+ }
+ EntryScreen(
+ entry = it,
+ onFeedClicked = { feed -> navController.navigate("feeds/${feed.id}") },
+ onCategoryClicked = { category -> navController.navigate("categories/${category.id}") },
+ onToggleReadClicked = entryViewModel::toggleRead,
+ onStarClicked = entryViewModel::toggleStar,
+ onShareClicked = { entry ->
+ coroutineScope.launch {
+ entryViewModel.share(entry)?.let { url ->
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(
+ Intent.EXTRA_TEXT,
+ url
+ )
+ }
+ context.startActivity(Intent.createChooser(intent, null))
+ }
+ }
+ },
+ onExternalLinkClicked = { entry ->
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(entry.url)))
+ },
+ onDownloadClicked = entryViewModel::downloadOriginalContent
+ )
}
}
+val cssOverride = """
+
+""".trimIndent()
+
@Composable
-fun Entry(entry: EntryAndFeed) {
+fun EntryScreen(
+ entry: EntryAndFeed,
+ onFeedClicked: (feed: Feed) -> Unit,
+ onCategoryClicked: (category: Category) -> Unit,
+ onToggleReadClicked: (entry: Entry) -> Unit,
+ onStarClicked: (entry: Entry) -> Unit,
+ onShareClicked: (entry: Entry) -> Unit,
+ onExternalLinkClicked: (entry: Entry) -> Unit,
+ onDownloadClicked: (entry: Entry) -> Unit,
+) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
- ) {
- EntryListItem(
- entry = entry.entry,
- feed = entry.feed,
- onEntryItemClicked = { /*TODO*/ },
- onFeedClicked = { /*TODO*/ },
- onExternalLinkClicked = { /*TODO*/ },
- onShareClicked = { /*TODO*/ },
- )
- // Adds view to Compose
- AndroidView(
+ ) {
+ Column(
modifier = Modifier
- .fillMaxSize()
- .padding(16.dp),
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ ) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ mainAxisSpacing = 8.dp,
+ mainAxisAlignment = FlowMainAxisAlignment.Start,
+ crossAxisAlignment = FlowCrossAxisAlignment.Center
+ ) {
+ FeedButton(
+ icon = entry.feed.icon,
+ feed = entry.feed.feed,
+ onFeedClicked = onFeedClicked
+ )
+ CategoryButton(
+ category = entry.feed.category,
+ onCategoryClicked = onCategoryClicked
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ val fontStyle = MaterialTheme.typography.body2.copy(
+ fontStyle = FontStyle.Italic,
+ fontSize = 12.sp
+ )
+ Text(text = entry.entry.publishedAt.timeSince(), style = fontStyle)
+ Text(text = "${entry.entry.readingTime}m read", style = fontStyle)
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ IconButton(onClick = { onToggleReadClicked(entry.entry) }) {
+ val (icon, description) = when (entry.entry.status) {
+ Entry.Status.UNREAD -> Icons.Outlined.Visibility to "Mark read"
+ else -> Icons.Outlined.VisibilityOff to "Make unread"
+ }
+ Icon(
+ imageVector = icon,
+ contentDescription = description
+ )
+ }
+ IconButton(onClick = { onStarClicked(entry.entry) }) {
+ Icon(
+ imageVector = Icons.Outlined.Star,
+ contentDescription = "Star"
+ )
+ }
+ IconButton(onClick = { onShareClicked(entry.entry) }) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = "Share"
+ )
+ }
+ IconButton(onClick = { onExternalLinkClicked(entry.entry) }) {
+ Icon(
+ imageVector = Icons.Outlined.OpenInBrowser,
+ contentDescription = "Open in browser"
+ )
+ }
+ IconButton(onClick = { onDownloadClicked(entry.entry) }) {
+ Icon(
+ imageVector = Icons.Outlined.Download,
+ contentDescription = "Download original content"
+ )
+ }
+ }
+ }
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
factory = { context ->
- TextView(context).apply {
- text = Html.fromHtml(entry.entry.content)
+ WebView(context).apply {
+ WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
+ settings.javaScriptEnabled = true
}
},
+ update = {
+ val baseUrl = Uri.parse(entry.entry.url)
+ .buildUpon()
+ .path("/")
+ .build()
+ .toString()
+ it.loadDataWithBaseURL(
+ baseUrl,
+ cssOverride + entry.entry.content,
+ "text/html",
+ null,
+ null
+ )
+ }
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt
index c51c36a..80f6633 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/MainScreen.kt
@@ -1,6 +1,5 @@
package com.wbrawner.nanoflux.ui
-import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -10,8 +9,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -20,14 +21,14 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
-import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.Constraints
+import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.SyncWorker
-import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
-import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
+import com.wbrawner.nanoflux.data.viewmodel.*
import kotlinx.coroutines.launch
-import java.util.*
+import java.time.Duration
@Composable
fun MainScreen(
@@ -36,8 +37,18 @@ fun MainScreen(
) {
val context = LocalContext.current
LaunchedEffect(key1 = context) {
+ val constraints = Constraints.Builder()
+ .setRequiresBatteryNotLow(true)
+ .setRequiresDeviceIdle(true)
+ .build()
+ val workRequest = PeriodicWorkRequestBuilder(
+ repeatInterval = Duration.ofHours(1),
+ flexTimeInterval = Duration.ofMinutes(45)
+ )
+ .setConstraints(constraints)
+ .build()
WorkManager.getInstance(context)
- .enqueue(OneTimeWorkRequestBuilder().build())
+ .enqueue(workRequest)
}
val navController = rememberNavController()
MainScaffold(
@@ -64,62 +75,146 @@ fun MainScaffold(
val snackbarHostState = remember { SnackbarHostState() }
val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState)
val coroutineScope = rememberCoroutineScope()
+ val (title, setTitle) = remember { mutableStateOf("Unread") }
Scaffold(
scaffoldState = scaffoldState,
topBar = {
TopAppBar(
+ modifier = Modifier.statusBarsPadding(),
navigationIcon = {
- IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
+ IconButton(onClick = {
+ coroutineScope.launch {
+ if (navController.currentDestination?.route?.startsWith("entries/") == true) {
+ navController.popBackStack()
+ } else {
+ scaffoldState.drawerState.open()
+ }
+ }
+ }) {
+ val icon =
+ if (navController.currentDestination?.route?.startsWith("entries/") == true) {
+ Icons.Default.ArrowBack
+ } else {
+ Icons.Default.Menu
+ }
Icon(
- imageVector = Icons.Default.Menu,
+ imageVector = icon,
contentDescription = "Menu"
)
}
},
title = {
Text(
- text = navController.currentDestination?.displayName?.capitalize(Locale.ENGLISH)
- ?: "Unread"
+ text = title,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
})
},
drawerContent = {
- DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread")
- DrawerButton(onClick = onStarredClicked, icon = Icons.Default.Star, text = "Starred")
+ val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp
+ Row(Modifier.padding(top = topPadding, start = 8.dp, end = 8.dp, bottom = 8.dp)) {
+ Text(text = "nano", fontSize = 24.sp)
+ Text(text = "flux", color = MaterialTheme.colors.primary, fontSize = 24.sp)
+ }
DrawerButton(
- onClick = onHistoryClicked,
+ onClick = {
+ onUnreadClicked()
+ coroutineScope.launch {
+ scaffoldState.drawerState.close()
+ }
+ },
+ icon = Icons.Default.Email, text = "Unread"
+ )
+ DrawerButton(
+ onClick = {
+ onStarredClicked()
+ coroutineScope.launch {
+ scaffoldState.drawerState.close()
+ }
+ },
+ icon = Icons.Default.Star,
+ text = "Starred"
+ )
+ DrawerButton(
+ onClick = {
+ onHistoryClicked()
+ coroutineScope.launch {
+ scaffoldState.drawerState.close()
+ }
+ },
icon = Icons.Default.DateRange,
text = "History"
)
- DrawerButton(onClick = onFeedsClicked, icon = Icons.Default.List, text = "Feeds")
DrawerButton(
- onClick = onCategoriesClicked,
+ onClick = {
+ onFeedsClicked()
+ coroutineScope.launch {
+ scaffoldState.drawerState.close()
+ }
+ },
+ icon = Icons.Default.List,
+ text = "Feeds"
+ )
+ DrawerButton(
+ onClick = {
+ onCategoriesClicked()
+ coroutineScope.launch {
+ scaffoldState.drawerState.close()
+ }
+ },
icon = Icons.Default.Info,
text = "Categories"
)
DrawerButton(
- onClick = onSettingsClicked,
+ onClick = {
+ onSettingsClicked()
+ coroutineScope.launch {
+ scaffoldState.drawerState.close()
+ }
+ },
icon = Icons.Default.Settings,
text = "Settings"
)
}
) {
// TODO: Extract routes to constants
- NavHost(navController, startDestination = "unread") {
+ NavHost(
+ modifier = Modifier.padding(it),
+ navController = navController,
+ startDestination = "unread"
+ ) {
composable("unread") {
- UnreadScreen(navController, snackbarHostState, hiltViewModel())
+ LaunchedEffect(navController.currentBackStackEntry) {
+ setTitle("Unread")
+ }
+ EntryListScreen(navController, snackbarHostState, hiltViewModel())
}
composable("starred") {
- UnreadScreen(navController, snackbarHostState, hiltViewModel())
+ LaunchedEffect(navController.currentBackStackEntry) {
+ setTitle("Starred")
+ }
+ EntryListScreen(navController, snackbarHostState, hiltViewModel())
}
composable("history") {
- UnreadScreen(navController, snackbarHostState, hiltViewModel())
+ LaunchedEffect(navController.currentBackStackEntry) {
+ setTitle("History")
+ }
+ EntryListScreen(navController, snackbarHostState, hiltViewModel())
}
composable(
"entries/{entryId}",
arguments = listOf(navArgument("entryId") { type = NavType.LongType })
) {
- EntryScreen(it.arguments!!.getLong("entryId"), hiltViewModel())
+ LaunchedEffect(navController.currentBackStackEntry) {
+ setTitle("")
+ }
+ EntryScreen(
+ it.arguments!!.getLong("entryId"),
+ navController,
+ hiltViewModel(),
+ setTitle,
+ )
}
}
}
@@ -133,11 +228,10 @@ fun DrawerButton(onClick: () -> Unit, icon: ImageVector, text: String) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
- Image(
+ Icon(
imageVector = icon,
- contentDescription = null, // decorative
-// colorFilter = ColorFilter.tint(textIconColor),
-// alpha = imageAlpha
+ contentDescription = null,
+ tint = MaterialTheme.colors.onSurface
)
Spacer(Modifier.width(16.dp))
Text(
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/auth/AuthScreen.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/auth/AuthScreen.kt
index 7598ba4..0d0a608 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/auth/AuthScreen.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/auth/AuthScreen.kt
@@ -7,16 +7,13 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
-import androidx.compose.ui.focus.*
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.*
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -24,6 +21,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.theme.NanofluxTheme
@@ -31,18 +29,32 @@ import timber.log.Timber
@Composable
fun AuthScreen(authViewModel: AuthViewModel) {
- val vmState = authViewModel.state.collectAsState()
- val state = vmState.value
- if (state is AuthViewModel.AuthState.Loading) {
- CircularProgressIndicator()
- } else if (state is AuthViewModel.AuthState.Unauthenticated) {
- AuthForm(
- state.server,
- state.username,
- state.password,
- authViewModel::login,
- state.errorMessage
- )
+ val state by authViewModel.state.collectAsState()
+ Column(
+ Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(8.dp), horizontalArrangement = Arrangement.Center) {
+ Text(text = "nano", fontSize = 24.sp)
+ Text(text = "flux", color = MaterialTheme.colors.primary, fontSize = 24.sp)
+ }
+ if (state is AuthViewModel.AuthState.Loading) {
+ CircularProgressIndicator()
+ } else if (state is AuthViewModel.AuthState.Unauthenticated) {
+
+ val s = state as AuthViewModel.AuthState.Unauthenticated
+ AuthForm(
+ s.server,
+ s.username,
+ s.password,
+ authViewModel::login,
+ s.errorMessage
+ )
+ }
}
}
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Color.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Color.kt
index 04830bb..0f9a4b4 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Color.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Color.kt
@@ -2,7 +2,9 @@ package com.wbrawner.nanoflux.ui.theme
import androidx.compose.ui.graphics.Color
-val Purple200 = Color(0xFFBB86FC)
-val Purple500 = Color(0xFF6200EE)
-val Purple700 = Color(0xFF3700B3)
-val Teal200 = Color(0xFF03DAC5)
\ No newline at end of file
+val Green200 = Color(0xFFA5D6A7)
+val Green500 = Color(0xFF4CAF50)
+val Green700 = Color(0xFF388E3C)
+val Yellow700 = Color(0xFFFBC02D)
+val Blue200 = Color(0xFF90CAF9)
+val Blue700 = Color(0xFF1976D2)
\ No newline at end of file
diff --git a/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Theme.kt b/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Theme.kt
index ac01afe..eab6bf9 100644
--- a/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Theme.kt
+++ b/app/src/main/java/com/wbrawner/nanoflux/ui/theme/Theme.kt
@@ -7,24 +7,24 @@ import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors(
- primary = Purple200,
- primaryVariant = Purple700,
- secondary = Teal200
+ primary = Green200,
+ primaryVariant = Green700,
+ secondary = Blue200
)
private val LightColorPalette = lightColors(
- primary = Purple500,
- primaryVariant = Purple700,
- secondary = Teal200
+ primary = Green500,
+ primaryVariant = Green700,
+ secondary = Blue700
- /* Other default colors to override
- background = Color.White,
- surface = Color.White,
- onPrimary = Color.White,
- onSecondary = Color.Black,
- onBackground = Color.Black,
- onSurface = Color.Black,
- */
+ /* Other default colors to override
+background = Color.White,
+surface = Color.White,
+onPrimary = Color.White,
+onSecondary = Color.Black,
+onBackground = Color.Black,
+onSurface = Color.Black,
+*/
)
@Composable
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index dbbe8cc..eebc811 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,16 +1,17 @@
-
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..af1b930 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,10 +1,11 @@
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
+ #FFA5D6A7
+ #FF4CAF50
+ #FF388E3C
+ #FF90CAF9
+ #FF1976D2
+ #282828
#FF000000
#FFFFFFFF
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index b0d6c1d..969c529 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,16 +1,16 @@
-
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 19dfbc0..bd7ffdb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,4 +1,5 @@
[versions]
+accompanist = "0.26.3-beta"
androidx-core = "1.9.0"
androidx-appcompat = "1.5.1"
compose-ui = "1.2.1"
@@ -18,7 +19,9 @@ versionName = "1.0"
work = "2.7.1"
[libraries]
-accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version = "0.26.3-beta" }
+accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
+accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanist" }
+accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
material = { module = "com.google.android.material:material", version.ref = "material" }
@@ -45,10 +48,10 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "
kotlin-reflection = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.4.0" }
ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
-ktor-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-logging = { module = "io.ktor:ktor-client-logging-jvm", version.ref = "ktor" }
+ktor-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-kapt = { module = "androidx.room:room-compiler", version.ref = "room" }
@@ -62,6 +65,6 @@ work-test = { module = "androidx.work:work-testing", version.ref = "work" }
[bundles]
compose = ["compose-compiler", "compose-ui", "compose-material", "compose-icons", "compose-tooling", "compose-activity", "navigation-compose"]
coroutines = ["coroutines-core", "coroutines-android"]
-networking = ["kotlin-reflection", "kotlinx-serialization", "ktor-core", "ktor-cio", "ktor-content-negotiation", "ktor-json", "ktor-logging", "ktor-serialization"]
+networking = ["kotlin-reflection", "kotlinx-serialization", "ktor-core", "ktor-okhttp", "ktor-content-negotiation", "ktor-json", "ktor-logging", "ktor-serialization"]
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt
index ac909c5..dd5f86b 100644
--- a/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/MinifluxApiService.kt
@@ -6,7 +6,7 @@ import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN
import com.wbrawner.nanoflux.storage.model.*
import io.ktor.client.*
import io.ktor.client.call.*
-import io.ktor.client.engine.cio.*
+import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
@@ -94,6 +94,8 @@ interface MinifluxApiService {
suspend fun shareEntry(entry: Entry): HttpResponse
+ suspend fun fetchOriginalContent(entry: Entry): EntryContentResponse
+
suspend fun getCategories(): List
suspend fun createCategory(title: String): Category
@@ -171,6 +173,9 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long)
@Serializable
data class EntryResponse(val total: Long, val entries: List)
+@Serializable
+data class EntryContentResponse(val content: String)
+
@Serializable
data class UpdateEntryRequest(
@SerialName("entry_ids") val entryIds: List,
@@ -182,7 +187,7 @@ class KtorMinifluxApiService @Inject constructor(
private val sharedPreferences: SharedPreferences,
private val _logger: Timber.Tree
) : MinifluxApiService {
- private val client = HttpClient(CIO) {
+ private val client = HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json {
isLenient = true
@@ -400,6 +405,16 @@ class KtorMinifluxApiService @Inject constructor(
}
}
+ override suspend fun fetchOriginalContent(entry: Entry): EntryContentResponse =
+ client.get(url("entries/${entry.id}/fetch-content")) {
+ headers {
+ header(
+ "Authorization",
+ sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString()
+ )
+ }
+ }.body()
+
override suspend fun getCategories(): List = client.get(url("categories")) {
headers {
header("Authorization", sharedPreferences.getString(PREF_KEY_AUTH_TOKEN, "").toString())
diff --git a/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt
index 5e0b7bb..ec99d29 100644
--- a/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt
+++ b/network/src/main/java/com/wbrawner/nanoflux/network/repository/EntryRepository.kt
@@ -20,6 +20,8 @@ class EntryRepository @Inject constructor(
private val entryDao: EntryDao,
private val logger: Timber.Tree
) {
+ fun observeRead(): Flow> = entryDao.observeRead()
+ fun observeStarred(): Flow> = entryDao.observeStarred()
fun observeUnread(): Flow> = entryDao.observeUnread()
fun getCount(): Long = entryDao.getCount()
@@ -52,13 +54,12 @@ class EntryRepository @Inject constructor(
return entryDao.getAllUnread()
}
- suspend fun getEntry(id: Long): EntryAndFeed {
- entryDao.getAllByIds(id).firstOrNull()?.let {
- return@getEntry it
+ suspend fun getEntry(id: Long): Flow {
+ val entry = entryDao.observe(id)
+ 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) {
@@ -95,13 +96,18 @@ class EntryRepository @Inject constructor(
return response.headers[HttpHeaders.Location]
}
+ suspend fun download(entry: Entry) {
+ val response = apiService.fetchOriginalContent(entry)
+ entryDao.update(entry.copy(content = response.content))
+ }
+
fun getLatestId(): Long = entryDao.getLatestId()
private suspend fun getEntries(request: suspend (page: Int) -> EntryResponse) {
var page = 0
var totalPages = 1L
- while (page++ < totalPages) {
- val response = request(page)
+ while (page < totalPages) {
+ val response = request(page++)
entryDao.insertAll(
response.entries
)
diff --git a/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/1.json b/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/1.json
index 309bf1b..bc2cc31 100644
--- a/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/1.json
+++ b/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/1.json
@@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
- "identityHash": "afcf22cfdfd6aec5ea5cb19ceaa9b3bc",
+ "identityHash": "d70fee9b0f6949ca4277985d65fedf40",
"entities": [
{
"tableName": "Feed",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT NOT NULL, `siteUrl` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `checkedAt` INTEGER NOT NULL, `etagHeader` TEXT NOT NULL, `lastModifiedHeader` TEXT NOT NULL, `parsingErrorMessage` TEXT NOT NULL, `parsingErrorCount` INTEGER NOT NULL, `scraperRules` TEXT NOT NULL, `rewriteRules` TEXT NOT NULL, `crawler` INTEGER NOT NULL, `blocklistRules` TEXT NOT NULL, `keeplistRules` TEXT NOT NULL, `userAgent` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `disabled` INTEGER NOT NULL, `ignoreHttpCache` INTEGER NOT NULL, `fetchViaProxy` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `iconId` INTEGER, PRIMARY KEY(`id`))",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT NOT NULL, `siteUrl` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `checkedAt` INTEGER NOT NULL, `etagHeader` TEXT NOT NULL, `lastModifiedHeader` TEXT NOT NULL, `parsingErrorMessage` TEXT NOT NULL, `parsingErrorCount` INTEGER NOT NULL, `scraperRules` TEXT NOT NULL, `rewriteRules` TEXT NOT NULL, `crawler` INTEGER NOT NULL, `blocklistRules` TEXT NOT NULL, `keeplistRules` TEXT NOT NULL, `userAgent` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `disabled` INTEGER NOT NULL, `ignoreHttpCache` INTEGER NOT NULL, `fetchViaProxy` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `iconId` INTEGER, `hideGlobally` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -145,6 +145,12 @@
"columnName": "iconId",
"affinity": "INTEGER",
"notNull": false
+ },
+ {
+ "fieldPath": "hideGlobally",
+ "columnName": "hideGlobally",
+ "affinity": "INTEGER",
+ "notNull": false
}
],
"primaryKey": {
@@ -268,7 +274,7 @@
},
{
"tableName": "Category",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `userId` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `userId` INTEGER NOT NULL, `hideGlobally` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -287,6 +293,12 @@
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
+ },
+ {
+ "fieldPath": "hideGlobally",
+ "columnName": "hideGlobally",
+ "affinity": "INTEGER",
+ "notNull": false
}
],
"primaryKey": {
@@ -444,7 +456,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'afcf22cfdfd6aec5ea5cb19ceaa9b3bc')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd70fee9b0f6949ca4277985d65fedf40')"
]
}
}
\ No newline at end of file
diff --git a/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/2.json b/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/2.json
new file mode 100644
index 0000000..131a873
--- /dev/null
+++ b/storage/schemas/com.wbrawner.nanoflux.storage.NanofluxDatabase/2.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/NanofluxDatabase.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/NanofluxDatabase.kt
index 7ed5eea..3d7d69f 100644
--- a/storage/src/main/java/com/wbrawner/nanoflux/storage/NanofluxDatabase.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/NanofluxDatabase.kt
@@ -8,7 +8,7 @@ import java.util.*
@Database(
entities = [com.wbrawner.nanoflux.storage.model.Feed::class, com.wbrawner.nanoflux.storage.model.Entry::class, com.wbrawner.nanoflux.storage.model.Category::class, com.wbrawner.nanoflux.storage.model.Feed.Icon::class, com.wbrawner.nanoflux.storage.model.User::class],
- version = 1,
+ version = 2,
exportSchema = true,
)
@TypeConverters(DateTypeConverter::class)
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/StorageModule.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/StorageModule.kt
index 6be558f..bdb7d83 100644
--- a/storage/src/main/java/com/wbrawner/nanoflux/storage/StorageModule.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/StorageModule.kt
@@ -22,7 +22,9 @@ object StorageModule {
@Provides
@Singleton
fun providesNanofluxDatabase(@ApplicationContext context: Context): NanofluxDatabase =
- Room.databaseBuilder(context, NanofluxDatabase::class.java, "nanoflux").build()
+ Room.databaseBuilder(context, NanofluxDatabase::class.java, "nanoflux")
+ .fallbackToDestructiveMigrationFrom(1)
+ .build()
@Provides
@Singleton
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt
index 9e71046..9dc55cd 100644
--- a/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/dao/EntryDao.kt
@@ -14,14 +14,26 @@ interface EntryDao {
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
fun observeAll(): Flow>
- @Transaction
- @Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY publishedAt DESC")
- fun observeUnread(): Flow>
-
@Transaction
@Query("SELECT * FROM Entry WHERE status = \"READ\" ORDER BY publishedAt DESC")
fun observeRead(): Flow>
+ @Transaction
+ @Query("SELECT * FROM Entry ORDER BY Entry.publishedAt DESC")
+ fun observeStarred(): Flow>
+
+ @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>
+
@Transaction
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
fun getAll(): List
@@ -31,8 +43,12 @@ interface EntryDao {
fun getAllUnread(): List
@Transaction
- @Query("SELECT * FROM Entry WHERE id in (:ids) ORDER BY createdAt DESC")
- fun getAllByIds(vararg ids: Long): List
+ @Query("SELECT * FROM Entry WHERE id = :id")
+ fun observe(id: Long): Flow
+
+ @Transaction
+ @Query("SELECT * FROM Entry WHERE id = :id")
+ fun get(id: Long): EntryAndFeed?
@Transaction
@Query("SELECT * FROM Entry WHERE feedId in (:ids) ORDER BY createdAt DESC")
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Category.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Category.kt
index 6a3ba65..ccf595c 100644
--- a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Category.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Category.kt
@@ -13,4 +13,6 @@ data class Category(
val title: String,
@SerialName("user_id")
val userId: Long,
+ @SerialName("hide_globally")
+ val hideGlobally: Boolean?
)
\ No newline at end of file
diff --git a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Feed.kt b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Feed.kt
index f0606d4..ec757fb 100644
--- a/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Feed.kt
+++ b/storage/src/main/java/com/wbrawner/nanoflux/storage/model/Feed.kt
@@ -33,7 +33,8 @@ data class Feed(
val ignoreHttpCache: Boolean,
val fetchViaProxy: Boolean,
val categoryId: Long,
- val iconId: Long?
+ val iconId: Long?,
+ val hideGlobally: Boolean?
) {
@Entity
@Serializable
@@ -104,7 +105,9 @@ data class FeedJson(
val fetchViaProxy: Boolean,
val category: Category,
@SerialName("icon")
- val icon: IconJson?
+ val icon: IconJson?,
+ @SerialName("hide_globally")
+ val hideGlobally: Boolean?
) {
@Serializable
data class IconJson(
@@ -137,6 +140,7 @@ data class FeedJson(
ignoreHttpCache,
fetchViaProxy,
category.id,
- icon?.iconId
+ icon?.iconId,
+ hideGlobally
)
}
\ No newline at end of file