More progress on entries
This commit is contained in:
parent
7e243f1853
commit
2eba6c7c75
16 changed files with 398 additions and 101 deletions
|
@ -14,6 +14,13 @@
|
|||
<entry key="../../../../layout/compose-model-1622575673269.xml" value="0.2170608108108108" />
|
||||
<entry key="../../../../layout/compose-model-1622578435598.xml" value="0.2170608108108108" />
|
||||
<entry key="../../../../layout/compose-model-1622578490958.xml" value="0.2361111111111111" />
|
||||
<entry key="../../../../layout/compose-model-1622633411023.xml" value="0.3091216216216216" />
|
||||
<entry key="../../../../layout/compose-model-1622633755661.xml" value="0.4212962962962963" />
|
||||
<entry key="../../../../layout/compose-model-1622646013817.xml" value="0.1638888888888889" />
|
||||
<entry key="../../../../layout/compose-model-1622658640119.xml" value="0.2170608108108108" />
|
||||
<entry key="../../../../layout/compose-model-1622768616366.xml" value="0.2179054054054054" />
|
||||
<entry key="../../../../layout/compose-model-1622823049252.xml" value="2.0" />
|
||||
<entry key="../../../../layout/compose-model-1623272040152.xml" value="0.6351851851851852" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
|
|
|
@ -61,6 +61,7 @@ dependencies {
|
|||
implementation(libs.lifecycle)
|
||||
implementation(libs.hilt.android.core)
|
||||
kapt(libs.hilt.android.kapt)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
implementation(libs.hilt.work.core)
|
||||
kapt(libs.hilt.work.kapt)
|
||||
implementation(libs.work.core)
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.wbrawner.nanoflux.network.repository.IconRepository
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltWorker
|
||||
|
@ -33,14 +34,9 @@ class SyncWorker @AssistedInject constructor(
|
|||
|
||||
override fun doWork(): Result {
|
||||
return runBlocking {
|
||||
Timber.v("Unread entryRepo: ${entryRepository}r")
|
||||
try {
|
||||
categoryRepository.getAll(true)
|
||||
feedRepository.getAll(true).forEach {
|
||||
if (it.feed.iconId != null) {
|
||||
iconRepository.getFeedIcon(it.feed.id)
|
||||
}
|
||||
}
|
||||
entryRepository.getAll(true)
|
||||
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build())
|
||||
|
|
23
app/src/main/java/com/wbrawner/nanoflux/Utils.kt
Normal file
23
app/src/main/java/com/wbrawner/nanoflux/Utils.kt
Normal file
|
@ -0,0 +1,23 @@
|
|||
package com.wbrawner.nanoflux
|
||||
|
||||
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
|
||||
|
||||
suspend fun syncAll(
|
||||
categoryRepository: CategoryRepository,
|
||||
feedRepository: FeedRepository,
|
||||
iconRepository: IconRepository,
|
||||
entryRepository: EntryRepository
|
||||
) {
|
||||
// The order of operations is important here to prevent the DAOs from returning incomplete
|
||||
// objects. E.g. The EntryDao returns an EntryAndFeed, which in turn contains a FeedCategoryIcon
|
||||
categoryRepository.getAll(true)
|
||||
feedRepository.getAll(true).forEach {
|
||||
if (it.feed.iconId != null) {
|
||||
iconRepository.getFeedIcon(it.feed.id)
|
||||
}
|
||||
}
|
||||
entryRepository.getAll(true, entryRepository.getLatestId())
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
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.storage.model.Entry
|
||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||
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 kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class EntryViewModel @Inject constructor(
|
||||
private val feedRepository: FeedRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val entryRepository: EntryRepository,
|
||||
private val logger: Timber.Tree
|
||||
) : ViewModel() {
|
||||
private val _loading = MutableStateFlow(true)
|
||||
val loading = _loading.asStateFlow()
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage = _errorMessage.asStateFlow()
|
||||
private val _entry = MutableStateFlow<EntryAndFeed?>(null)
|
||||
val entry = _entry.asStateFlow()
|
||||
|
||||
suspend fun loadEntry(id: Long) {
|
||||
withContext(Dispatchers.IO) {
|
||||
_loading.value = true
|
||||
_errorMessage.value = null
|
||||
try {
|
||||
val entry = entryRepository.getEntry(id)
|
||||
_entry.value = entry
|
||||
_loading.value = false
|
||||
entryRepository.markEntryRead(entry.entry)
|
||||
} catch (e: Exception) {
|
||||
_errorMessage.value = e.message?: "Error fetching entry"
|
||||
_loading.value = false
|
||||
logger.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
// TODO: Get Base URL
|
||||
fun getShareUrl(entry: Entry) = "baseUrl/${entry.shareCode}"
|
||||
}
|
|
@ -5,7 +5,10 @@ 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.storage.model.EntryAndFeed
|
||||
import com.wbrawner.nanoflux.syncAll
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -18,29 +21,16 @@ import javax.inject.Inject
|
|||
class MainViewModel @Inject constructor(
|
||||
private val feedRepository: FeedRepository,
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val iconRepository: IconRepository,
|
||||
private val entryRepository: EntryRepository,
|
||||
private val logger: Timber.Tree
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow<FeedState>(FeedState.Loading)
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
fun loadUnread() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
var entries = entryRepository.getAllUnread()
|
||||
if (entries.isEmpty()) entries = entryRepository.getAllUnread(true)
|
||||
val state = if (entries.isEmpty()) FeedState.Empty else FeedState.Success(entries)
|
||||
_state.emit(state)
|
||||
} catch (e: Exception) {
|
||||
_state.emit(FeedState.Failed(e.localizedMessage))
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
if (entryRepository.getCount() == 0L) {
|
||||
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class FeedState {
|
||||
object Loading : FeedState()
|
||||
class Failed(val errorMessage: String?): FeedState()
|
||||
object Empty: FeedState()
|
||||
class Success(val entries: List<EntryAndFeed>): FeedState()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
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.storage.model.Entry
|
||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
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 logger: Timber.Tree
|
||||
) : ViewModel() {
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading = _loading.asStateFlow()
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage = _errorMessage.asStateFlow()
|
||||
val entries = entryRepository.observeUnread()
|
||||
|
||||
init {
|
||||
logger.v("Unread entryRepo: ${entryRepository}r")
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
// TODO: Get Base URL
|
||||
fun getShareUrl(entry: Entry) = "baseUrl/${entry.shareCode}"
|
||||
}
|
|
@ -34,6 +34,7 @@ fun EntryList(
|
|||
onStarClicked: (entry: Entry) -> Unit,
|
||||
onExternalLinkClicked: (entry: Entry) -> Unit
|
||||
) {
|
||||
// TODO: Add pull to refresh
|
||||
LazyColumn {
|
||||
items(entries) { entry ->
|
||||
EntryListItem(
|
||||
|
@ -209,8 +210,9 @@ private const val MILLIS_IN_WEEK = 604_800_000.0
|
|||
private const val MILLIS_IN_MONTH = 2_628_000_000.0
|
||||
private const val MILLIS_IN_YEAR = 31_540_000_000.0
|
||||
|
||||
val now = GregorianCalendar().timeInMillis
|
||||
fun Date.timeSince(): String {
|
||||
val difference = System.currentTimeMillis() - time
|
||||
val difference = now - time
|
||||
return when {
|
||||
difference >= MILLIS_IN_YEAR -> "${(difference / MILLIS_IN_YEAR).roundToInt()} years ago"
|
||||
difference >= MILLIS_IN_MONTH -> "${(difference / MILLIS_IN_MONTH).roundToInt()} months ago"
|
||||
|
|
69
app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt
Normal file
69
app/src/main/java/com/wbrawner/nanoflux/ui/EntryScreen.kt
Normal file
|
@ -0,0 +1,69 @@
|
|||
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 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.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.wbrawner.nanoflux.data.viewmodel.EntryViewModel
|
||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||
|
||||
@Composable
|
||||
fun EntryScreen(entryId: Long, entryViewModel: EntryViewModel) {
|
||||
val loading by entryViewModel.loading.collectAsState()
|
||||
val errorMessage by entryViewModel.errorMessage.collectAsState()
|
||||
val entry by entryViewModel.entry.collectAsState()
|
||||
LaunchedEffect(key1 = entryId) {
|
||||
entryViewModel.loadEntry(entryId)
|
||||
}
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
errorMessage?.let {
|
||||
Text("Unable to load entry: $it")
|
||||
}
|
||||
entry?.let {
|
||||
Entry(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Entry(entry: EntryAndFeed) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
EntryListItem(
|
||||
entry = entry.entry,
|
||||
feed = entry.feed,
|
||||
onEntryItemClicked = { /*TODO*/ },
|
||||
onFeedClicked = { /*TODO*/ },
|
||||
onToggleReadClicked = { /*TODO*/ },
|
||||
onStarClicked = { /*TODO*/ },
|
||||
onExternalLinkClicked = { /* TODO */ }
|
||||
)
|
||||
// Adds view to Compose
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
text = Html.fromHtml(entry.entry.content)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -5,18 +5,20 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.*
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.navArgument
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
|
@ -25,6 +27,7 @@ import com.wbrawner.nanoflux.SyncWorker
|
|||
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
|
||||
import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
|
@ -32,30 +35,24 @@ fun MainScreen(
|
|||
mainViewModel: MainViewModel = viewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(key1 = authViewModel.state) {
|
||||
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>().build()
|
||||
WorkManager.getInstance(context).enqueue(workRequest)
|
||||
mainViewModel.loadUnread()
|
||||
LaunchedEffect(key1 = context) {
|
||||
WorkManager.getInstance(context)
|
||||
.enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())
|
||||
}
|
||||
val state = mainViewModel.state.collectAsState()
|
||||
val navController = rememberNavController()
|
||||
MainScaffold(
|
||||
state.value,
|
||||
navController,
|
||||
{
|
||||
|
||||
},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{}
|
||||
{ navController.navigate("unread") },
|
||||
{ navController.navigate("starred") },
|
||||
{ navController.navigate("history") },
|
||||
{ navController.navigate("feeds") },
|
||||
{ navController.navigate("categories") },
|
||||
{ navController.navigate("settings") },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScaffold(
|
||||
state: MainViewModel.FeedState,
|
||||
navController: NavHostController,
|
||||
onUnreadClicked: () -> Unit,
|
||||
onStarredClicked: () -> Unit,
|
||||
|
@ -64,40 +61,60 @@ fun MainScaffold(
|
|||
onCategoriesClicked: () -> Unit,
|
||||
onSettingsClicked: () -> Unit,
|
||||
) {
|
||||
val scaffoldState = rememberScaffoldState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Scaffold(
|
||||
scaffoldState = scaffoldState,
|
||||
topBar = {
|
||||
TopAppBar(navigationIcon = {
|
||||
IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = "Menu"
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = "Menu"
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = navController.currentDestination?.displayName?.capitalize(Locale.ENGLISH)
|
||||
?: "Unread"
|
||||
)
|
||||
}
|
||||
}, title = { Text(text = "Unread") })
|
||||
})
|
||||
},
|
||||
drawerContent = {
|
||||
DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread")
|
||||
DrawerButton(onClick = onStarredClicked, icon = Icons.Default.Star, text = "Starred")
|
||||
DrawerButton(onClick = onHistoryClicked, icon = Icons.Default.DateRange, text = "History")
|
||||
DrawerButton(
|
||||
onClick = onHistoryClicked,
|
||||
icon = Icons.Default.DateRange,
|
||||
text = "History"
|
||||
)
|
||||
DrawerButton(onClick = onFeedsClicked, icon = Icons.Default.List, text = "Feeds")
|
||||
DrawerButton(onClick = onCategoriesClicked, icon = Icons.Default.Info, text = "Categories")
|
||||
DrawerButton(onClick = onSettingsClicked, icon = Icons.Default.Settings, text = "Settings")
|
||||
DrawerButton(
|
||||
onClick = onCategoriesClicked,
|
||||
icon = Icons.Default.Info,
|
||||
text = "Categories"
|
||||
)
|
||||
DrawerButton(
|
||||
onClick = onSettingsClicked,
|
||||
icon = Icons.Default.Settings,
|
||||
text = "Settings"
|
||||
)
|
||||
}
|
||||
) {
|
||||
when (state) {
|
||||
is MainViewModel.FeedState.Loading -> CircularProgressIndicator()
|
||||
is MainViewModel.FeedState.Failed -> Text("TODO: Failed to load entries: ${state.errorMessage}")
|
||||
is MainViewModel.FeedState.Success -> EntryList(
|
||||
entries = state.entries,
|
||||
onEntryItemClicked = { /*TODO*/ },
|
||||
onFeedClicked = { /*TODO*/ },
|
||||
onToggleReadClicked = { /*TODO*/ },
|
||||
onStarClicked = { /*TODO*/ }) {
|
||||
// TODO: Extract routes to constants
|
||||
NavHost(navController, startDestination = "unread") {
|
||||
composable("unread") {
|
||||
UnreadScreen(navController, snackbarHostState, hiltViewModel())
|
||||
}
|
||||
composable(
|
||||
"entries/{entryId}",
|
||||
arguments = listOf(navArgument("entryId") { type = NavType.LongType })
|
||||
) {
|
||||
EntryScreen(it.arguments!!.getLong("entryId"), hiltViewModel())
|
||||
}
|
||||
is MainViewModel.FeedState.Empty -> Text("TODO: No entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -132,6 +149,6 @@ fun DrawerButton(onClick: () -> Unit, icon: ImageVector, text: String) {
|
|||
fun MainScaffold_Preview() {
|
||||
NanofluxApp {
|
||||
val navController = rememberNavController()
|
||||
MainScaffold(MainViewModel.FeedState.Loading, navController, {}, {}, {}, {}, {}, {})
|
||||
MainScaffold(navController, {}, {}, {}, {}, {}, {})
|
||||
}
|
||||
}
|
46
app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt
Normal file
46
app/src/main/java/com/wbrawner/nanoflux/ui/UnreadScreen.kt
Normal file
|
@ -0,0 +1,46 @@
|
|||
package com.wbrawner.nanoflux.ui
|
||||
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.wbrawner.nanoflux.data.viewmodel.UnreadViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UnreadScreen(
|
||||
navController: NavController,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
unreadViewModel: UnreadViewModel = viewModel()
|
||||
) {
|
||||
val loading by unreadViewModel.loading.collectAsState()
|
||||
val errorMessage by unreadViewModel.errorMessage.collectAsState()
|
||||
val entries by unreadViewModel.entries.collectAsState(emptyList())
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
errorMessage?.let {
|
||||
coroutineScope.launch {
|
||||
when (snackbarHostState.showSnackbar(it, "Retry")) {
|
||||
// SnackbarResult.ActionPerformed -> unreadViewModel.loadUnread()
|
||||
else -> unreadViewModel.dismissError()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
Text("TODO: No entries")
|
||||
} else {
|
||||
EntryList(
|
||||
entries = entries,
|
||||
onEntryItemClicked = {
|
||||
navController.navigate("entries/${it.id}")
|
||||
},
|
||||
onFeedClicked = {
|
||||
navController.navigate("feeds/${it.id}")
|
||||
},
|
||||
onToggleReadClicked = { /*TODO*/ },
|
||||
onStarClicked = { /*TODO*/ }) {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -32,6 +32,7 @@ preference = { module = "androidx.preference:preference-ktx", version = "1.1.1"
|
|||
lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.3.1" }
|
||||
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
|
||||
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
|
||||
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0-alpha02" }
|
||||
hilt-work-core = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" }
|
||||
hilt-work-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" }
|
||||
junit = { module = "junit:junit", version = "4.12" }
|
||||
|
|
|
@ -5,6 +5,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.engine.cio.*
|
||||
import io.ktor.client.features.*
|
||||
import io.ktor.client.features.json.*
|
||||
import io.ktor.client.features.json.serializer.*
|
||||
import io.ktor.client.features.logging.*
|
||||
|
@ -166,6 +167,9 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long)
|
|||
@Serializable
|
||||
data class EntryResponse(val total: Long, val entries: List<Entry>)
|
||||
|
||||
@Serializable
|
||||
data class UpdateEntryRequest(@SerialName("entry_ids") val entryIds: List<Long>, val status: Entry.Status)
|
||||
|
||||
@Suppress("unused")
|
||||
class KtorMinifluxApiService @Inject constructor(
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
|
@ -185,7 +189,7 @@ class KtorMinifluxApiService @Inject constructor(
|
|||
}
|
||||
|
||||
}
|
||||
level = LogLevel.ALL
|
||||
level = LogLevel.HEADERS
|
||||
}
|
||||
}
|
||||
private val _baseUrl: AtomicReference<String?> = AtomicReference()
|
||||
|
@ -276,6 +280,7 @@ class KtorMinifluxApiService @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun updateFeed(id: Long, feed: FeedJson): Feed = client.put(url("feeds/$id")) {
|
||||
contentType(ContentType.Application.Json)
|
||||
body = feed
|
||||
}
|
||||
|
||||
|
@ -362,10 +367,8 @@ class KtorMinifluxApiService @Inject constructor(
|
|||
|
||||
override suspend fun updateEntries(entryIds: List<Long>, status: Entry.Status): HttpResponse =
|
||||
client.put(url("entries")) {
|
||||
// body = @Serializable object {
|
||||
// val entry_ids = entryIds
|
||||
// val status = status
|
||||
// }
|
||||
contentType(ContentType.Application.Json)
|
||||
body = UpdateEntryRequest(entryIds, status)
|
||||
}
|
||||
|
||||
override suspend fun toggleEntryBookmark(id: Long): HttpResponse =
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package com.wbrawner.nanoflux.network.repository
|
||||
|
||||
import com.wbrawner.nanoflux.network.EntryResponse
|
||||
import com.wbrawner.nanoflux.network.MinifluxApiService
|
||||
import com.wbrawner.nanoflux.storage.dao.EntryDao
|
||||
import com.wbrawner.nanoflux.storage.model.Entry
|
||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -12,15 +14,18 @@ class EntryRepository @Inject constructor(
|
|||
private val entryDao: EntryDao,
|
||||
private val logger: Timber.Tree
|
||||
) {
|
||||
suspend fun getAll(fetch: Boolean = false): List<EntryAndFeed> {
|
||||
fun observeUnread(): Flow<List<EntryAndFeed>> = entryDao.observeAll()
|
||||
|
||||
fun getCount(): Long = entryDao.getCount()
|
||||
|
||||
suspend fun getAll(fetch: Boolean = false, afterId: Long = 0): List<EntryAndFeed> {
|
||||
if (fetch) {
|
||||
var page = 0
|
||||
while (true) {
|
||||
try {
|
||||
entryDao.insertAll(apiService.getEntries(offset = 1000L * page++, limit = 1000).entries)
|
||||
} catch (e: Exception) {
|
||||
break
|
||||
}
|
||||
getEntries { page ->
|
||||
apiService.getEntries(
|
||||
offset = 100L * page,
|
||||
limit = 100,
|
||||
afterEntryId = afterId
|
||||
)
|
||||
}
|
||||
}
|
||||
return entryDao.getAll()
|
||||
|
@ -28,23 +33,47 @@ class EntryRepository @Inject constructor(
|
|||
|
||||
suspend fun getAllUnread(fetch: Boolean = false): List<EntryAndFeed> {
|
||||
if (fetch) {
|
||||
var page = 0
|
||||
while (true) {
|
||||
// try {
|
||||
val response = apiService.getEntries(
|
||||
offset = 10000000000L,// * page++,
|
||||
limit = 1000,
|
||||
status = listOf(Entry.Status.UNREAD)
|
||||
)
|
||||
entryDao.insertAll(
|
||||
response.entries
|
||||
)
|
||||
// } catch (e: Exception) {
|
||||
// Log.e("EntryRepository", "Error", e)
|
||||
// break
|
||||
// }
|
||||
getEntries { page ->
|
||||
apiService.getEntries(
|
||||
offset = 100L * page,
|
||||
limit = 100,
|
||||
status = listOf(Entry.Status.UNREAD)
|
||||
)
|
||||
}
|
||||
}
|
||||
return entryDao.getAllUnread()
|
||||
}
|
||||
|
||||
suspend fun getEntry(id: Long): EntryAndFeed {
|
||||
entryDao.getAllByIds(id).firstOrNull()?.let {
|
||||
return@getEntry it
|
||||
}
|
||||
|
||||
entryDao.insertAll(apiService.getEntry(id))
|
||||
return entryDao.getAllByIds(id).first()
|
||||
}
|
||||
|
||||
suspend fun markEntryRead(entry: Entry) {
|
||||
apiService.updateEntries(listOf(entry.id), Entry.Status.READ)
|
||||
entryDao.update(entry.copy(status = Entry.Status.READ))
|
||||
}
|
||||
|
||||
suspend fun markEntryUnread(entry: Entry) {
|
||||
apiService.updateEntries(listOf(entry.id), Entry.Status.UNREAD)
|
||||
entryDao.update(entry.copy(status = Entry.Status.UNREAD))
|
||||
}
|
||||
|
||||
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)
|
||||
entryDao.insertAll(
|
||||
response.entries
|
||||
)
|
||||
totalPages = response.total / 100
|
||||
}
|
||||
}
|
||||
}
|
|
@ -38,7 +38,7 @@ class UserRepository @Inject constructor(
|
|||
var correctedServer = if (server.startsWith("http")) {
|
||||
server
|
||||
} else {
|
||||
"http://$server"
|
||||
"https://$server"
|
||||
}
|
||||
if (!correctedServer.endsWith("/v1")) {
|
||||
correctedServer = if (correctedServer.endsWith("/")) {
|
||||
|
|
|
@ -3,32 +3,49 @@ package com.wbrawner.nanoflux.storage.dao
|
|||
import androidx.room.*
|
||||
import com.wbrawner.nanoflux.storage.model.Entry
|
||||
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface EntryDao {
|
||||
@Query("SELECT COUNT(*) FROM Entry")
|
||||
fun getCount(): Long
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry")
|
||||
@Query("SELECT * FROM Entry ORDER BY createdAt DESC")
|
||||
fun observeAll(): Flow<List<EntryAndFeed>>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry ORDER BY createdAt DESC")
|
||||
fun getAll(): List<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\"")
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY createdAt DESC")
|
||||
fun getAllUnread(): List<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE id in (:ids)")
|
||||
@Query("SELECT * FROM Entry WHERE id in (:ids) ORDER BY createdAt DESC")
|
||||
fun getAllByIds(vararg ids: Long): List<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE feedId in (:ids)")
|
||||
@Query("SELECT * FROM Entry WHERE feedId in (:ids) ORDER BY createdAt DESC")
|
||||
fun getAllByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids)")
|
||||
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids) ORDER BY createdAt DESC")
|
||||
fun getAllUnreadByFeedIds(vararg ids: Long): List<EntryAndFeed>
|
||||
|
||||
@Query("SELECT id FROM Entry ORDER BY id DESC LIMIT 1")
|
||||
fun getLatestId(): Long
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(entries: List<Entry>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insertAll(vararg entry: Entry)
|
||||
|
||||
@Update
|
||||
fun update(entry: Entry)
|
||||
|
||||
@Delete
|
||||
fun delete(entry: Entry)
|
||||
}
|
Loading…
Reference in a new issue