More progress on entries

This commit is contained in:
William Brawner 2021-06-10 21:19:49 -06:00
parent 7e243f1853
commit 2eba6c7c75
16 changed files with 398 additions and 101 deletions

View file

@ -14,6 +14,13 @@
<entry key="../../../../layout/compose-model-1622575673269.xml" value="0.2170608108108108" /> <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-1622578435598.xml" value="0.2170608108108108" />
<entry key="../../../../layout/compose-model-1622578490958.xml" value="0.2361111111111111" /> <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> </map>
</option> </option>
</component> </component>

View file

@ -61,6 +61,7 @@ dependencies {
implementation(libs.lifecycle) implementation(libs.lifecycle)
implementation(libs.hilt.android.core) implementation(libs.hilt.android.core)
kapt(libs.hilt.android.kapt) kapt(libs.hilt.android.kapt)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.work.core) implementation(libs.hilt.work.core)
kapt(libs.hilt.work.kapt) kapt(libs.hilt.work.kapt)
implementation(libs.work.core) implementation(libs.work.core)

View file

@ -12,6 +12,7 @@ import com.wbrawner.nanoflux.network.repository.IconRepository
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltWorker @HiltWorker
@ -33,14 +34,9 @@ class SyncWorker @AssistedInject constructor(
override fun doWork(): Result { override fun doWork(): Result {
return runBlocking { return runBlocking {
Timber.v("Unread entryRepo: ${entryRepository}r")
try { try {
categoryRepository.getAll(true) syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
feedRepository.getAll(true).forEach {
if (it.feed.iconId != null) {
iconRepository.getFeedIcon(it.feed.id)
}
}
entryRepository.getAll(true)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build()) Result.failure(Data.Builder().putString("message", e.message?: "Unknown failure").build())

View 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())
}

View file

@ -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}"
}

View file

@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope
import com.wbrawner.nanoflux.network.repository.CategoryRepository import com.wbrawner.nanoflux.network.repository.CategoryRepository
import com.wbrawner.nanoflux.network.repository.EntryRepository import com.wbrawner.nanoflux.network.repository.EntryRepository
import com.wbrawner.nanoflux.network.repository.FeedRepository import com.wbrawner.nanoflux.network.repository.FeedRepository
import com.wbrawner.nanoflux.network.repository.IconRepository
import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import com.wbrawner.nanoflux.syncAll
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -18,29 +21,16 @@ import javax.inject.Inject
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
private val feedRepository: FeedRepository, private val feedRepository: FeedRepository,
private val categoryRepository: CategoryRepository, private val categoryRepository: CategoryRepository,
private val iconRepository: IconRepository,
private val entryRepository: EntryRepository, private val entryRepository: EntryRepository,
private val logger: Timber.Tree private val logger: Timber.Tree
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow<FeedState>(FeedState.Loading)
val state = _state.asStateFlow()
fun loadUnread() { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
try { if (entryRepository.getCount() == 0L) {
var entries = entryRepository.getAllUnread() syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
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))
} }
} }
} }
sealed class FeedState {
object Loading : FeedState()
class Failed(val errorMessage: String?): FeedState()
object Empty: FeedState()
class Success(val entries: List<EntryAndFeed>): FeedState()
}
} }

View file

@ -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}"
}

View file

@ -34,6 +34,7 @@ fun EntryList(
onStarClicked: (entry: Entry) -> Unit, onStarClicked: (entry: Entry) -> Unit,
onExternalLinkClicked: (entry: Entry) -> Unit onExternalLinkClicked: (entry: Entry) -> Unit
) { ) {
// TODO: Add pull to refresh
LazyColumn { LazyColumn {
items(entries) { entry -> items(entries) { entry ->
EntryListItem( 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_MONTH = 2_628_000_000.0
private const val MILLIS_IN_YEAR = 31_540_000_000.0 private const val MILLIS_IN_YEAR = 31_540_000_000.0
val now = GregorianCalendar().timeInMillis
fun Date.timeSince(): String { fun Date.timeSince(): String {
val difference = System.currentTimeMillis() - time val difference = now - time
return when { return when {
difference >= MILLIS_IN_YEAR -> "${(difference / MILLIS_IN_YEAR).roundToInt()} years ago" difference >= MILLIS_IN_YEAR -> "${(difference / MILLIS_IN_YEAR).roundToInt()} years ago"
difference >= MILLIS_IN_MONTH -> "${(difference / MILLIS_IN_MONTH).roundToInt()} months ago" difference >= MILLIS_IN_MONTH -> "${(difference / MILLIS_IN_MONTH).roundToInt()} months ago"

View 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)
}
},
)
}
}

View file

@ -5,18 +5,20 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
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.navigation.compose.rememberNavController
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager 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.AuthViewModel
import com.wbrawner.nanoflux.data.viewmodel.MainViewModel import com.wbrawner.nanoflux.data.viewmodel.MainViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
@Composable @Composable
fun MainScreen( fun MainScreen(
@ -32,30 +35,24 @@ fun MainScreen(
mainViewModel: MainViewModel = viewModel() mainViewModel: MainViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(key1 = authViewModel.state) { LaunchedEffect(key1 = context) {
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>().build() WorkManager.getInstance(context)
WorkManager.getInstance(context).enqueue(workRequest) .enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())
mainViewModel.loadUnread()
} }
val state = mainViewModel.state.collectAsState()
val navController = rememberNavController() val navController = rememberNavController()
MainScaffold( MainScaffold(
state.value,
navController, navController,
{ { navController.navigate("unread") },
{ navController.navigate("starred") },
}, { navController.navigate("history") },
{}, { navController.navigate("feeds") },
{}, { navController.navigate("categories") },
{}, { navController.navigate("settings") },
{},
{}
) )
} }
@Composable @Composable
fun MainScaffold( fun MainScaffold(
state: MainViewModel.FeedState,
navController: NavHostController, navController: NavHostController,
onUnreadClicked: () -> Unit, onUnreadClicked: () -> Unit,
onStarredClicked: () -> Unit, onStarredClicked: () -> Unit,
@ -64,40 +61,60 @@ fun MainScaffold(
onCategoriesClicked: () -> Unit, onCategoriesClicked: () -> Unit,
onSettingsClicked: () -> Unit, onSettingsClicked: () -> Unit,
) { ) {
val scaffoldState = rememberScaffoldState() val snackbarHostState = remember { SnackbarHostState() }
val scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
Scaffold( Scaffold(
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
topBar = { topBar = {
TopAppBar(navigationIcon = { TopAppBar(
IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) { navigationIcon = {
Icon( IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
imageVector = Icons.Default.Menu, Icon(
contentDescription = "Menu" imageVector = Icons.Default.Menu,
contentDescription = "Menu"
)
}
},
title = {
Text(
text = navController.currentDestination?.displayName?.capitalize(Locale.ENGLISH)
?: "Unread"
) )
} })
}, title = { Text(text = "Unread") })
}, },
drawerContent = { drawerContent = {
DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread") DrawerButton(onClick = onUnreadClicked, icon = Icons.Default.Email, text = "Unread")
DrawerButton(onClick = onStarredClicked, icon = Icons.Default.Star, text = "Starred") 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 = onFeedsClicked, icon = Icons.Default.List, text = "Feeds")
DrawerButton(onClick = onCategoriesClicked, icon = Icons.Default.Info, text = "Categories") DrawerButton(
DrawerButton(onClick = onSettingsClicked, icon = Icons.Default.Settings, text = "Settings") onClick = onCategoriesClicked,
icon = Icons.Default.Info,
text = "Categories"
)
DrawerButton(
onClick = onSettingsClicked,
icon = Icons.Default.Settings,
text = "Settings"
)
} }
) { ) {
when (state) { // TODO: Extract routes to constants
is MainViewModel.FeedState.Loading -> CircularProgressIndicator() NavHost(navController, startDestination = "unread") {
is MainViewModel.FeedState.Failed -> Text("TODO: Failed to load entries: ${state.errorMessage}") composable("unread") {
is MainViewModel.FeedState.Success -> EntryList( UnreadScreen(navController, snackbarHostState, hiltViewModel())
entries = state.entries, }
onEntryItemClicked = { /*TODO*/ }, composable(
onFeedClicked = { /*TODO*/ }, "entries/{entryId}",
onToggleReadClicked = { /*TODO*/ }, arguments = listOf(navArgument("entryId") { type = NavType.LongType })
onStarClicked = { /*TODO*/ }) { ) {
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() { fun MainScaffold_Preview() {
NanofluxApp { NanofluxApp {
val navController = rememberNavController() val navController = rememberNavController()
MainScaffold(MainViewModel.FeedState.Loading, navController, {}, {}, {}, {}, {}, {}) MainScaffold(navController, {}, {}, {}, {}, {}, {})
} }
} }

View 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*/ }) {
}
}
}

View file

@ -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" } 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-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-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-core = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" }
hilt-work-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" } hilt-work-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" }
junit = { module = "junit:junit", version = "4.12" } junit = { module = "junit:junit", version = "4.12" }

View file

@ -5,6 +5,7 @@ import com.wbrawner.nanoflux.network.repository.PREF_KEY_AUTH_TOKEN
import com.wbrawner.nanoflux.storage.model.* import com.wbrawner.nanoflux.storage.model.*
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.features.*
import io.ktor.client.features.json.* import io.ktor.client.features.json.*
import io.ktor.client.features.json.serializer.* import io.ktor.client.features.json.serializer.*
import io.ktor.client.features.logging.* import io.ktor.client.features.logging.*
@ -166,6 +167,9 @@ data class CreateFeedResponse(@SerialName("feed_id") val feedId: Long)
@Serializable @Serializable
data class EntryResponse(val total: Long, val entries: List<Entry>) data class EntryResponse(val total: Long, val entries: List<Entry>)
@Serializable
data class UpdateEntryRequest(@SerialName("entry_ids") val entryIds: List<Long>, val status: Entry.Status)
@Suppress("unused") @Suppress("unused")
class KtorMinifluxApiService @Inject constructor( class KtorMinifluxApiService @Inject constructor(
private val sharedPreferences: SharedPreferences, private val sharedPreferences: SharedPreferences,
@ -185,7 +189,7 @@ class KtorMinifluxApiService @Inject constructor(
} }
} }
level = LogLevel.ALL level = LogLevel.HEADERS
} }
} }
private val _baseUrl: AtomicReference<String?> = AtomicReference() 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")) { override suspend fun updateFeed(id: Long, feed: FeedJson): Feed = client.put(url("feeds/$id")) {
contentType(ContentType.Application.Json)
body = feed body = feed
} }
@ -362,10 +367,8 @@ class KtorMinifluxApiService @Inject constructor(
override suspend fun updateEntries(entryIds: List<Long>, status: Entry.Status): HttpResponse = override suspend fun updateEntries(entryIds: List<Long>, status: Entry.Status): HttpResponse =
client.put(url("entries")) { client.put(url("entries")) {
// body = @Serializable object { contentType(ContentType.Application.Json)
// val entry_ids = entryIds body = UpdateEntryRequest(entryIds, status)
// val status = status
// }
} }
override suspend fun toggleEntryBookmark(id: Long): HttpResponse = override suspend fun toggleEntryBookmark(id: Long): HttpResponse =

View file

@ -1,9 +1,11 @@
package com.wbrawner.nanoflux.network.repository package com.wbrawner.nanoflux.network.repository
import com.wbrawner.nanoflux.network.EntryResponse
import com.wbrawner.nanoflux.network.MinifluxApiService import com.wbrawner.nanoflux.network.MinifluxApiService
import com.wbrawner.nanoflux.storage.dao.EntryDao import com.wbrawner.nanoflux.storage.dao.EntryDao
import com.wbrawner.nanoflux.storage.model.Entry import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import kotlinx.coroutines.flow.Flow
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -12,15 +14,18 @@ class EntryRepository @Inject constructor(
private val entryDao: EntryDao, private val entryDao: EntryDao,
private val logger: Timber.Tree 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) { if (fetch) {
var page = 0 getEntries { page ->
while (true) { apiService.getEntries(
try { offset = 100L * page,
entryDao.insertAll(apiService.getEntries(offset = 1000L * page++, limit = 1000).entries) limit = 100,
} catch (e: Exception) { afterEntryId = afterId
break )
}
} }
} }
return entryDao.getAll() return entryDao.getAll()
@ -28,23 +33,47 @@ class EntryRepository @Inject constructor(
suspend fun getAllUnread(fetch: Boolean = false): List<EntryAndFeed> { suspend fun getAllUnread(fetch: Boolean = false): List<EntryAndFeed> {
if (fetch) { if (fetch) {
var page = 0 getEntries { page ->
while (true) { apiService.getEntries(
// try { offset = 100L * page,
val response = apiService.getEntries( limit = 100,
offset = 10000000000L,// * page++, status = listOf(Entry.Status.UNREAD)
limit = 1000, )
status = listOf(Entry.Status.UNREAD)
)
entryDao.insertAll(
response.entries
)
// } catch (e: Exception) {
// Log.e("EntryRepository", "Error", e)
// break
// }
} }
} }
return entryDao.getAllUnread() 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
}
}
} }

View file

@ -38,7 +38,7 @@ class UserRepository @Inject constructor(
var correctedServer = if (server.startsWith("http")) { var correctedServer = if (server.startsWith("http")) {
server server
} else { } else {
"http://$server" "https://$server"
} }
if (!correctedServer.endsWith("/v1")) { if (!correctedServer.endsWith("/v1")) {
correctedServer = if (correctedServer.endsWith("/")) { correctedServer = if (correctedServer.endsWith("/")) {

View file

@ -3,32 +3,49 @@ package com.wbrawner.nanoflux.storage.dao
import androidx.room.* import androidx.room.*
import com.wbrawner.nanoflux.storage.model.Entry import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface EntryDao { interface EntryDao {
@Query("SELECT COUNT(*) FROM Entry")
fun getCount(): Long
@Transaction @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> fun getAll(): List<EntryAndFeed>
@Transaction @Transaction
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\"") @Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY createdAt DESC")
fun getAllUnread(): List<EntryAndFeed> fun getAllUnread(): List<EntryAndFeed>
@Transaction @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> fun getAllByIds(vararg ids: Long): List<EntryAndFeed>
@Transaction @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> fun getAllByFeedIds(vararg ids: Long): List<EntryAndFeed>
@Transaction @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> 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(entries: List<Entry>) fun insertAll(entries: List<Entry>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(vararg entry: Entry)
@Update
fun update(entry: Entry)
@Delete @Delete
fun delete(entry: Entry) fun delete(entry: Entry)
} }