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